mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ac64643e0 | |||
| 64950cd358 | |||
| 36157938a8 | |||
| c0dea334fb | |||
| 9b49daf3a9 | |||
| d6f1b08ce8 | |||
| 75cf4cc8a8 | |||
| 4a181c3574 | |||
| bfddf05782 | |||
| cdee625a13 | |||
| 5f7091ab4e | |||
| e7e7a97648 | |||
| f58b8cb67b | |||
| 775f687ca9 | |||
| 265a93927d | |||
| 3a938bb6d8 | |||
| 6f9a4e70da | |||
| dedb4133dc | |||
| 21f8184c58 | |||
| 59fe350972 | |||
| 5bdfcaac3c | |||
| 323d687173 | |||
| cdca203be2 | |||
| 77a31d2b6e | |||
| a1ed99278a | |||
| 729bd438e4 | |||
| 7304bb2751 | |||
| 456521f689 | |||
| ee69079bb4 | |||
| de14bb2e0f | |||
| 0539d56fc4 | |||
| 729adde022 | |||
| b7111d5c62 | |||
| 1669713fbc | |||
| cb8be9ba12 | |||
| 15046658ba | |||
| 7255ce9a57 | |||
| 2b9cf5d388 | |||
| a625d35f7b | |||
| 449c64928b | |||
| 35c88c3fc8 | |||
| b5e09024d5 | |||
| 9a51765bd2 | |||
| bb31c9b92c | |||
| 5117679148 | |||
| 0d5798c0bf | |||
| 808677a612 | |||
| fe41e9d482 | |||
| 0768c4655e | |||
| 1586a26594 | |||
| f026c1422c | |||
| b7fdb595df | |||
| 049ee1ce5e | |||
| e71fbb6d48 | |||
| c9976d69c0 | |||
| 27734409e4 | |||
| e434ce01d3 | |||
| c2f93ae2ad | |||
| 92cc8f553a | |||
| 49d997aec3 | |||
| 67aa9cee23 | |||
| 7ec3d67829 | |||
| 5401ebaede | |||
| 99668cfdce |
@@ -18,4 +18,4 @@ simonklee
|
|||||||
Slickstef11
|
Slickstef11
|
||||||
usrnk1
|
usrnk1
|
||||||
vimtor
|
vimtor
|
||||||
starptech
|
StarpTech
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@corvu/drawer": "catalog:",
|
"@corvu/drawer": "catalog:",
|
||||||
"@dnd-kit/abstract": "0.5.0",
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lildax": "./bin/lildax.cjs",
|
"lildax": "./bin/lildax.cjs",
|
||||||
},
|
},
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
},
|
},
|
||||||
"packages/codemode": {
|
"packages/codemode": {
|
||||||
"name": "@opencode-ai/codemode",
|
"name": "@opencode-ai/codemode",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"acorn": "8.15.0",
|
"acorn": "8.15.0",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -266,7 +266,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/support": {
|
"packages/console/support": {
|
||||||
"name": "@opencode-ai/console-support",
|
"name": "@opencode-ai/console-support",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@opencode-ai/console-core": "workspace:*",
|
"@opencode-ai/console-core": "workspace:*",
|
||||||
@@ -286,7 +286,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -294,7 +294,7 @@
|
|||||||
"@ai-sdk/alibaba": "1.0.17",
|
"@ai-sdk/alibaba": "1.0.17",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.88",
|
||||||
"@ai-sdk/cerebras": "2.0.41",
|
"@ai-sdk/cerebras": "2.0.41",
|
||||||
"@ai-sdk/cohere": "3.0.27",
|
"@ai-sdk/cohere": "3.0.27",
|
||||||
"@ai-sdk/deepinfra": "2.0.41",
|
"@ai-sdk/deepinfra": "2.0.41",
|
||||||
@@ -303,7 +303,7 @@
|
|||||||
"@ai-sdk/google-vertex": "4.0.128",
|
"@ai-sdk/google-vertex": "4.0.128",
|
||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.27",
|
"@ai-sdk/mistral": "3.0.27",
|
||||||
"@ai-sdk/openai": "3.0.53",
|
"@ai-sdk/openai": "3.0.84",
|
||||||
"@ai-sdk/openai-compatible": "2.0.41",
|
"@ai-sdk/openai-compatible": "2.0.41",
|
||||||
"@ai-sdk/perplexity": "3.0.26",
|
"@ai-sdk/perplexity": "3.0.26",
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
@@ -338,7 +338,7 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.10.0",
|
"gitlab-ai-provider": "6.11.1",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
@@ -380,7 +380,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -434,7 +434,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-drizzle-sqlite": {
|
"packages/effect-drizzle-sqlite": {
|
||||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -448,7 +448,7 @@
|
|||||||
},
|
},
|
||||||
"packages/effect-sqlite-node": {
|
"packages/effect-sqlite-node": {
|
||||||
"name": "@opencode-ai/effect-sqlite-node",
|
"name": "@opencode-ai/effect-sqlite-node",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/standard-validator": "catalog:",
|
"@hono/standard-validator": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -492,7 +492,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -508,7 +508,7 @@
|
|||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.83",
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||||
@@ -539,7 +539,7 @@
|
|||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
@@ -558,7 +558,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -569,7 +569,7 @@
|
|||||||
"@ai-sdk/alibaba": "1.0.17",
|
"@ai-sdk/alibaba": "1.0.17",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.88",
|
||||||
"@ai-sdk/cerebras": "2.0.60",
|
"@ai-sdk/cerebras": "2.0.60",
|
||||||
"@ai-sdk/cohere": "3.0.27",
|
"@ai-sdk/cohere": "3.0.27",
|
||||||
"@ai-sdk/deepinfra": "2.0.41",
|
"@ai-sdk/deepinfra": "2.0.41",
|
||||||
@@ -578,7 +578,7 @@
|
|||||||
"@ai-sdk/google-vertex": "4.0.128",
|
"@ai-sdk/google-vertex": "4.0.128",
|
||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.27",
|
"@ai-sdk/mistral": "3.0.27",
|
||||||
"@ai-sdk/openai": "3.0.53",
|
"@ai-sdk/openai": "3.0.84",
|
||||||
"@ai-sdk/openai-compatible": "2.0.41",
|
"@ai-sdk/openai-compatible": "2.0.41",
|
||||||
"@ai-sdk/perplexity": "3.0.26",
|
"@ai-sdk/perplexity": "3.0.26",
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
@@ -631,7 +631,7 @@
|
|||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.10.0",
|
"gitlab-ai-provider": "6.11.1",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
@@ -689,7 +689,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -765,7 +765,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -780,7 +780,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@opencode-ai/server",
|
"name": "@opencode-ai/server",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
@@ -795,7 +795,7 @@
|
|||||||
},
|
},
|
||||||
"packages/session-ui": {
|
"packages/session-ui": {
|
||||||
"name": "@opencode-ai/session-ui",
|
"name": "@opencode-ai/session-ui",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
@@ -839,7 +839,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -852,9 +852,10 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/app": {
|
"packages/stats/app": {
|
||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/stats-core": "workspace:*",
|
"@opencode-ai/stats-core": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
@@ -885,7 +886,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/core": {
|
"packages/stats/core": {
|
||||||
"name": "@opencode-ai/stats-core",
|
"name": "@opencode-ai/stats-core",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-athena": "3.933.0",
|
"@aws-sdk/client-athena": "3.933.0",
|
||||||
"@planetscale/database": "1.19.0",
|
"@planetscale/database": "1.19.0",
|
||||||
@@ -904,7 +905,7 @@
|
|||||||
},
|
},
|
||||||
"packages/stats/server": {
|
"packages/stats/server": {
|
||||||
"name": "@opencode-ai/stats-server",
|
"name": "@opencode-ai/stats-server",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-firehose": "3.933.0",
|
"@aws-sdk/client-firehose": "3.933.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
@@ -945,7 +946,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
@@ -972,7 +973,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
@@ -1023,7 +1024,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -1066,6 +1067,7 @@
|
|||||||
],
|
],
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
@@ -1073,10 +1075,8 @@
|
|||||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
@@ -1112,7 +1112,7 @@
|
|||||||
"@solidjs/router": "0.15.4",
|
"@solidjs/router": "0.15.4",
|
||||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"@tanstack/solid-virtual": "3.13.28",
|
"@tanstack/solid-virtual": "3.13.32",
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@tsconfig/node22": "22.0.2",
|
"@tsconfig/node22": "22.0.2",
|
||||||
"@types/bun": "1.3.13",
|
"@types/bun": "1.3.13",
|
||||||
@@ -1132,7 +1132,7 @@
|
|||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "17.0.1",
|
"marked": "17.0.6",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
"opentui-spinner": "0.0.7",
|
"opentui-spinner": "0.0.7",
|
||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
@@ -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/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="],
|
||||||
|
|
||||||
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
|
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="],
|
||||||
|
|
||||||
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
|
"@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/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
|
||||||
|
|
||||||
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
|
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="],
|
||||||
|
|
||||||
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="],
|
"@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-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
|
||||||
|
|
||||||
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="],
|
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="],
|
||||||
|
|
||||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="],
|
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
|
||||||
|
|
||||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
"@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=="],
|
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||||
|
|
||||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="],
|
"gitlab-ai-provider": ["gitlab-ai-provider@6.11.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="],
|
||||||
|
|
||||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
"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=="],
|
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||||
|
|
||||||
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
|
||||||
|
|
||||||
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
|
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
|
||||||
|
|
||||||
@@ -5654,7 +5654,11 @@
|
|||||||
|
|
||||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||||
|
|
||||||
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||||
|
|
||||||
|
"@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
|
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||||
|
|
||||||
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
@@ -5666,9 +5670,9 @@
|
|||||||
|
|
||||||
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||||
|
|
||||||
"@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
"@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||||
|
|
||||||
@@ -6020,7 +6024,7 @@
|
|||||||
|
|
||||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||||
|
|
||||||
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||||
|
|
||||||
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||||
|
|
||||||
@@ -6046,6 +6050,8 @@
|
|||||||
|
|
||||||
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||||
|
|
||||||
|
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||||
|
|
||||||
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||||
|
|
||||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||||
@@ -6162,6 +6168,10 @@
|
|||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
|
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
|
||||||
@@ -6388,7 +6398,7 @@
|
|||||||
|
|
||||||
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
|
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
|
||||||
|
|
||||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||||
|
|
||||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||||
|
|
||||||
@@ -6896,6 +6906,10 @@
|
|||||||
|
|
||||||
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||||
|
|
||||||
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
|
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
|
||||||
|
|
||||||
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||||
@@ -6966,6 +6980,14 @@
|
|||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
"ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||||
|
|
||||||
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
@@ -7118,6 +7140,10 @@
|
|||||||
|
|
||||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
|
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
|
||||||
|
|
||||||
|
"opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
|
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||||
|
|
||||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||||
@@ -7366,6 +7392,8 @@
|
|||||||
|
|
||||||
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||||
@@ -7382,6 +7410,10 @@
|
|||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||||
@@ -7452,6 +7484,8 @@
|
|||||||
|
|
||||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||||
|
|
||||||
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
|
"x86_64-linux": "sha256-F1luclnqCPQk9yxfmeSYGaM/nScf28yBu9K3Fv+Xd24=",
|
||||||
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
|
"aarch64-linux": "sha256-XW0XZnsCRkU3MFJH9TjMRYZHffzVy3cQyiNCkec2gl4=",
|
||||||
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
|
"aarch64-darwin": "sha256-bf8kvORs3Fs2UYLp3PekF+AJR7NKOcHb+fIQA79RtMk=",
|
||||||
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
|
"x86_64-darwin": "sha256-sBdQPkzd7JXNW6Lbi9JHiAsfHwdLwTKWY+uPeXAv2Nw="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-5
@@ -43,7 +43,7 @@
|
|||||||
"@opentui/core": "0.4.3",
|
"@opentui/core": "0.4.3",
|
||||||
"@opentui/keymap": "0.4.3",
|
"@opentui/keymap": "0.4.3",
|
||||||
"@opentui/solid": "0.4.3",
|
"@opentui/solid": "0.4.3",
|
||||||
"@tanstack/solid-virtual": "3.13.28",
|
"@tanstack/solid-virtual": "3.13.32",
|
||||||
"@shikijs/stream": "4.2.0",
|
"@shikijs/stream": "4.2.0",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "17.0.1",
|
"marked": "17.0.6",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
"remend": "1.3.0",
|
"remend": "1.3.0",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
@@ -152,10 +152,9 @@
|
|||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ export async function setupTimeline(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
if (settings.newLayoutDesigns === false) {
|
||||||
|
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
||||||
|
}
|
||||||
}, input.settings ?? {})
|
}, input.settings ?? {})
|
||||||
if (input.locale) {
|
if (input.locale) {
|
||||||
await page.addInitScript((locale) => {
|
await page.addInitScript((locale) => {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
|||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, draftID, server }) => {
|
({ directory, draftID, server }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||||
|
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.window.browser.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
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,6 +1,7 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const directory = "C:/OpenCode/RequestDocks"
|
const directory = "C:/OpenCode/RequestDocks"
|
||||||
@@ -100,6 +101,67 @@ test("shows a pending permission dock", async ({ page }) => {
|
|||||||
expect(request.postDataJSON()).toEqual({ response: "once" })
|
expect(request.postDataJSON()).toEqual({ response: "once" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||||
|
const transport = await installSseTransport(page, {
|
||||||
|
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||||
|
retry: 20,
|
||||||
|
})
|
||||||
|
await mockServer(page, { questions: [] })
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await transport.waitForConnection()
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
||||||
|
const draft = "keep the caret at the end"
|
||||||
|
await editor.fill(draft)
|
||||||
|
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
|
||||||
|
for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
|
||||||
|
const cursor = draft.length - 4
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
editor.evaluate((element) => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
|
||||||
|
const range = selection.getRangeAt(0).cloneRange()
|
||||||
|
range.selectNodeContents(element)
|
||||||
|
range.setEnd(selection.anchorNode!, selection.anchorOffset)
|
||||||
|
return range.toString().length
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(cursor)
|
||||||
|
await transport.send({
|
||||||
|
directory,
|
||||||
|
payload: {
|
||||||
|
type: "question.asked",
|
||||||
|
properties: {
|
||||||
|
id: "question-caret",
|
||||||
|
sessionID,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "Continue",
|
||||||
|
question: "Continue?",
|
||||||
|
options: [{ label: "Yes", description: "Continue the session" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool: { messageID: "message-caret", callID: "call-caret" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||||
|
await expect(question).toBeVisible()
|
||||||
|
await expect(editor).toHaveCount(0)
|
||||||
|
|
||||||
|
await transport.send({
|
||||||
|
directory,
|
||||||
|
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
||||||
|
})
|
||||||
|
await expect(question).toHaveCount(0)
|
||||||
|
await expect(editor).toBeVisible()
|
||||||
|
await page.keyboard.press("x")
|
||||||
|
|
||||||
|
await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
|
||||||
|
})
|
||||||
|
|
||||||
async function mockServer(
|
async function mockServer(
|
||||||
page: Page,
|
page: Page,
|
||||||
requests: {
|
requests: {
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
|||||||
import { installSseTransport } from "../utils/sse-transport"
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const assistants = Array.from({ length: 14 }, (_, index) =>
|
const initialPageSize = 20
|
||||||
|
const historyPageSize = 200
|
||||||
|
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
|
||||||
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
||||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||||
parentID: userID,
|
parentID: userID,
|
||||||
created: 1700000001000 + index * 1_000,
|
created: 1700000001000 + index * 1_000,
|
||||||
completed: index < 13,
|
completed: index < initialPageSize,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const messages = [userMessage(), ...assistants]
|
const messages = [userMessage(), ...assistants]
|
||||||
@@ -46,7 +48,7 @@ const scenarios = [
|
|||||||
test.use({ viewport: { width: 646, height: 1385 } })
|
test.use({ viewport: { width: 646, height: 1385 } })
|
||||||
|
|
||||||
for (const scenario of scenarios) {
|
for (const scenario of scenarios) {
|
||||||
test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => {
|
test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => {
|
||||||
const requests: { before?: string; phase: "start" | "end" }[] = []
|
const requests: { before?: string; phase: "start" | "end" }[] = []
|
||||||
const pages: { before?: string; limit: number }[] = []
|
const pages: { before?: string; limit: number }[] = []
|
||||||
const roots: { sessionID: string; messageID: string }[] = []
|
const roots: { sessionID: string; messageID: string }[] = []
|
||||||
@@ -101,36 +103,51 @@ for (const scenario of scenarios) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await page.addInitScript(
|
await page.addInitScript(() => {
|
||||||
({ userPartID, lastPartID }) => {
|
const visibleParts = () => {
|
||||||
const state = { armed: false, hidden: false, samples: 0, stop: false }
|
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||||
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
|
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||||
const sample = () => {
|
const view = viewport?.getBoundingClientRect()
|
||||||
if (state.armed) {
|
if (!viewport || !view) return []
|
||||||
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
return [...viewport.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
|
||||||
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
.filter((part) => {
|
||||||
const view = viewport?.getBoundingClientRect()
|
const rect = part.getBoundingClientRect()
|
||||||
const visible = (partID: string) => {
|
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||||
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
|
})
|
||||||
const rect = part?.getBoundingClientRect()
|
.flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : []))
|
||||||
return (
|
}
|
||||||
!!rect &&
|
const state = {
|
||||||
!!view &&
|
armed: false,
|
||||||
rect.width > 0 &&
|
hidden: false,
|
||||||
rect.height > 0 &&
|
visibleParts: [] as string[],
|
||||||
rect.bottom > view.top &&
|
samples: 0,
|
||||||
rect.top < view.bottom
|
stop: false,
|
||||||
)
|
arm() {
|
||||||
}
|
state.visibleParts = visibleParts()
|
||||||
if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
|
state.armed = true
|
||||||
state.samples++
|
},
|
||||||
|
}
|
||||||
|
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
|
||||||
|
const sample = () => {
|
||||||
|
if (state.armed) {
|
||||||
|
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||||
|
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||||
|
const view = viewport?.getBoundingClientRect()
|
||||||
|
const visible = (partID: string) => {
|
||||||
|
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(partID)}"]`)
|
||||||
|
const rect = part?.getBoundingClientRect()
|
||||||
|
return (
|
||||||
|
!!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID)))
|
||||||
|
state.hidden = true
|
||||||
|
state.samples++
|
||||||
}
|
}
|
||||||
requestAnimationFrame(() => setTimeout(sample, 0))
|
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
||||||
},
|
}
|
||||||
{ userPartID, lastPartID },
|
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||||
)
|
})
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await transport.waitForConnection()
|
await transport.waitForConnection()
|
||||||
@@ -143,23 +160,28 @@ for (const scenario of scenarios) {
|
|||||||
"messages:start:latest",
|
"messages:start:latest",
|
||||||
"messages:end:latest",
|
"messages:end:latest",
|
||||||
`message:${userID}`,
|
`message:${userID}`,
|
||||||
`messages:start:${messages.at(-2)!.info.id}`,
|
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
|
||||||
])
|
])
|
||||||
|
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
;(
|
;(
|
||||||
window as Window & {
|
window as Window & {
|
||||||
__historyRootProbe?: { armed: boolean }
|
__historyRootProbe?: { arm(): void }
|
||||||
}
|
}
|
||||||
).__historyRootProbe!.armed = true
|
).__historyRootProbe!.arm()
|
||||||
})
|
})
|
||||||
await waitForProbeSamples(page, 0)
|
await waitForProbeSamples(page, 0)
|
||||||
expect(await historyRootHidden(page)).toBe(false)
|
expect(await visibleContentHidden(page)).toBe(false)
|
||||||
const beforeHistory = await probeSamples(page)
|
const beforeHistory = await probeSamples(page)
|
||||||
history.resolve()
|
history.resolve()
|
||||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14)
|
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.getByRole("button", { name: "Stop" })).toBeVisible()
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
await waitForProbeSamples(page, beforeHistory)
|
await waitForProbeSamples(page, beforeHistory)
|
||||||
expect(pages[0]).toEqual({ before: undefined, limit: 2 })
|
expect(pages).toEqual([
|
||||||
|
{ before: undefined, limit: initialPageSize },
|
||||||
|
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
|
||||||
|
])
|
||||||
expect(roots).toEqual([{ sessionID, messageID: userID }])
|
expect(roots).toEqual([{ sessionID, messageID: userID }])
|
||||||
|
|
||||||
const message = messageUpdated(scenario.info)
|
const message = messageUpdated(scenario.info)
|
||||||
@@ -213,7 +235,7 @@ async function waitForProbeSamples(page: Page, after: number) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function historyRootHidden(page: Page) {
|
function visibleContentHidden(page: Page) {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
|
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
@@ -7,10 +7,11 @@ const directory = "C:/OpenCode/TerminalComposerFocus"
|
|||||||
const projectID = "proj_terminal_composer_focus"
|
const projectID = "proj_terminal_composer_focus"
|
||||||
const sessionID = "ses_terminal_composer_focus"
|
const sessionID = "ses_terminal_composer_focus"
|
||||||
const ptyID = "pty_terminal_composer_focus"
|
const ptyID = "pty_terminal_composer_focus"
|
||||||
|
const newPtyID = "pty_terminal_composer_focus_new"
|
||||||
|
|
||||||
test.use({ viewport: { width: 1440, height: 900 } })
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
@@ -67,7 +68,9 @@ test("routes typing to the composer unless the open terminal is focused", async
|
|||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, "Terminal composer focus")
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
@@ -87,3 +90,120 @@ test("routes typing to the composer unless the open terminal is focused", async
|
|||||||
await expect(composer).toBeFocused()
|
await expect(composer).toBeFocused()
|
||||||
await expect(composer).toHaveText("a")
|
await expect(composer).toHaveText("a")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => {
|
||||||
|
const ghostty = Promise.withResolvers<void>()
|
||||||
|
const release = Promise.withResolvers<void>()
|
||||||
|
const created = { count: 0 }
|
||||||
|
await page.route("**/pty", (route) => {
|
||||||
|
created.count += 1
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route(/ghostty-web/, async (route) => {
|
||||||
|
ghostty.resolve()
|
||||||
|
await release.promise
|
||||||
|
await route.continue()
|
||||||
|
})
|
||||||
|
await seedCachedTerminal(page)
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
|
||||||
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
|
const composer = page.locator('[data-component="prompt-input"]')
|
||||||
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
expect(created.count).toBe(0)
|
||||||
|
await ghostty.promise
|
||||||
|
await composer.click()
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
|
||||||
|
release.resolve()
|
||||||
|
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => {
|
||||||
|
const ghostty = Promise.withResolvers<void>()
|
||||||
|
const release = Promise.withResolvers<void>()
|
||||||
|
await page.route(/ghostty-web/, async (route) => {
|
||||||
|
ghostty.resolve()
|
||||||
|
await release.promise
|
||||||
|
await route.continue()
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
|
const composer = page.locator('[data-component="prompt-input"]')
|
||||||
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
await ghostty.promise
|
||||||
|
await composer.click()
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
|
||||||
|
release.resolve()
|
||||||
|
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||||
|
await page.waitForTimeout(50)
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
|
||||||
|
const created = { count: 0 }
|
||||||
|
await page.route("**/pty", (route) => {
|
||||||
|
created.count += 1
|
||||||
|
const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(next),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route(`**/pty/${newPtyID}`, (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||||
|
)
|
||||||
|
await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
|
const composer = page.locator('[data-component="prompt-input"]')
|
||||||
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||||
|
await composer.click()
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "New terminal" }).click()
|
||||||
|
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
|
||||||
|
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
function seedCachedTerminal(page: Page) {
|
||||||
|
return page.addInitScript(
|
||||||
|
({ terminalKey, ptyID }) => {
|
||||||
|
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
terminalKey,
|
||||||
|
JSON.stringify({
|
||||||
|
active: ptyID,
|
||||||
|
all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
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")!)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vite"
|
||||||
|
import solid from "vite-plugin-solid"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: import.meta.dirname,
|
||||||
|
plugins: [solid()],
|
||||||
|
})
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
"./performance/timeline-stability/fixture.test.ts",
|
"./performance/timeline-stability/fixture.test.ts",
|
||||||
"./performance/timeline-stability/fixture.ts",
|
"./performance/timeline-stability/fixture.ts",
|
||||||
"./performance/unit/visual-stability.test.ts",
|
"./performance/unit/visual-stability.test.ts",
|
||||||
|
"./reproduction/timeline-suspense/**/*.ts",
|
||||||
|
"./reproduction/timeline-suspense/**/*.tsx",
|
||||||
|
"../src/pages/session/timeline/observe-element-offset.ts",
|
||||||
"./regression/new-session-panel-corner.spec.ts",
|
"./regression/new-session-panel-corner.spec.ts",
|
||||||
"./regression/session-timeline-context-resize.spec.ts",
|
"./regression/session-timeline-context-resize.spec.ts",
|
||||||
"./utils/**/*.ts"
|
"./utils/**/*.ts"
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
const path = url.pathname
|
const path = url.pathname
|
||||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||||
if (path === "/global/health") return json(route, { healthy: true })
|
if (path === "/global/health") return json(route, { healthy: true })
|
||||||
|
if (path === "/api/session")
|
||||||
|
return json(route, {
|
||||||
|
data: config.sessions.map((session) => v2Session(session, config.directory)),
|
||||||
|
cursor: {},
|
||||||
|
})
|
||||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
|
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
|
||||||
if (path === "/permission")
|
if (path === "/permission")
|
||||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||||
@@ -132,6 +137,30 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) {
|
||||||
|
const time = session.time && typeof session.time === "object" ? session.time : {}
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
parentID: session.parentID,
|
||||||
|
projectID: session.projectID ?? "project",
|
||||||
|
cost: session.cost ?? 0,
|
||||||
|
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: {
|
||||||
|
created: "created" in time && typeof time.created === "number" ? time.created : 0,
|
||||||
|
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
|
||||||
|
...(session.time && typeof session.time === "object" && "archived" in session.time
|
||||||
|
? { archived: session.time.archived }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
title: session.title ?? session.id,
|
||||||
|
location: {
|
||||||
|
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
||||||
|
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
||||||
|
},
|
||||||
|
...(typeof session.path === "string" ? { subpath: session.path } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export async function installSseTransport<T>(
|
|||||||
const request = new Request(input, init)
|
const request = new Request(input, init)
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
||||||
return originalFetch(input, init)
|
return originalFetch(request)
|
||||||
|
|
||||||
const id = ++nextConnectionID
|
const id = ++nextConnectionID
|
||||||
const record = {
|
const record = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
+17
-27
@@ -153,8 +153,7 @@ function LegacyTargetSessionRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||||
// Notification/Models + the visual Layout) for that server.
|
|
||||||
function SelectedServerProviders(props: ParentProps) {
|
function SelectedServerProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
@@ -207,7 +206,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
|||||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>
|
<ServerSyncProvider server={conn}>
|
||||||
<DraftServerScopedProviders directory={directory}>
|
<ModelsProvider directory={directory}>
|
||||||
<SDKProvider directory={directory}>
|
<SDKProvider directory={directory}>
|
||||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||||
<DraftProviders>
|
<DraftProviders>
|
||||||
@@ -215,7 +214,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
|||||||
</DraftProviders>
|
</DraftProviders>
|
||||||
</DirectoryDataProvider>
|
</DirectoryDataProvider>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
</DraftServerScopedProviders>
|
</ModelsProvider>
|
||||||
</ServerSyncProvider>
|
</ServerSyncProvider>
|
||||||
</ServerSDKProvider>
|
</ServerSDKProvider>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -309,24 +308,21 @@ function DesktopCommands() {
|
|||||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||||
type ServerScopedShellProps = ParentProps<{
|
type ServerScopedShellProps = ParentProps<{
|
||||||
directory?: () => string | undefined
|
directory?: () => string | undefined
|
||||||
sessionID?: () => string | undefined
|
|
||||||
serverScoped?: JSX.Element
|
serverScoped?: JSX.Element
|
||||||
}>
|
}>
|
||||||
|
|
||||||
function ServerScopedProviders(props: ServerScopedShellProps) {
|
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<LayoutProvider>
|
||||||
<LayoutProvider>
|
{props.serverScoped}
|
||||||
{props.serverScoped}
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
</LayoutProvider>
|
||||||
</LayoutProvider>
|
|
||||||
</PermissionProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
|
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
|
||||||
<LegacyLayout>{props.children}</LegacyLayout>
|
<LegacyLayout>{props.children}</LegacyLayout>
|
||||||
</ServerScopedProviders>
|
</ServerScopedProviders>
|
||||||
)
|
)
|
||||||
@@ -342,14 +338,6 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
|
|
||||||
return (
|
|
||||||
<PermissionProvider directory={props.directory}>
|
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
|
||||||
</PermissionProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||||
function DraftProviders(props: ParentProps) {
|
function DraftProviders(props: ParentProps) {
|
||||||
@@ -559,13 +547,15 @@ export function AppInterface(props: {
|
|||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
<TabsProvider>
|
<TabsProvider>
|
||||||
<NotificationProvider>
|
<PermissionProvider>
|
||||||
<ServerShell>
|
<NotificationProvider>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
<ServerShell>
|
||||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
</Show>
|
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||||
</ServerShell>
|
</Show>
|
||||||
</NotificationProvider>
|
</ServerShell>
|
||||||
|
</NotificationProvider>
|
||||||
|
</PermissionProvider>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
@@ -1,11 +1,12 @@
|
|||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { createSignal, Show } from "solid-js"
|
import { createSignal, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import homeImage from "@/assets/help/home.png"
|
||||||
|
import tabsImage from "@/assets/help/tabs.png"
|
||||||
|
|
||||||
const helpIcon = (
|
const helpIcon = (
|
||||||
<svg
|
<svg
|
||||||
@@ -54,24 +55,21 @@ export function HelpButton() {
|
|||||||
|
|
||||||
// can remove this after the tabs rollout has been out for a while
|
// can remove this after the tabs rollout has been out for a while
|
||||||
export function TabsInfoPopup() {
|
export function TabsInfoPopup() {
|
||||||
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
|
const settings = useSettings()
|
||||||
|
|
||||||
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
|
|
||||||
// setState({ dismissed: false }) // for testing
|
|
||||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||||
<Show when={!state.dismissed}>
|
<Show when={settings.general.shouldDisplayTabsToast()}>
|
||||||
<div
|
<div
|
||||||
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
|
||||||
aria-label="Introducing Tabs. A faster, more intuitive way to work."
|
aria-label="Introducing Tabs. Organize your work and active sessions with tabs"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Dismiss Tabs information"
|
aria-label="Dismiss Tabs information"
|
||||||
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
|
||||||
onClick={() => setState("dismissed", true)}
|
onClick={settings.general.dismissTabsToast}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
@@ -88,7 +86,7 @@ export function TabsInfoPopup() {
|
|||||||
type="button"
|
type="button"
|
||||||
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setState("dismissed", true)
|
settings.general.dismissTabsToast()
|
||||||
setDrawerOpen(true)
|
setDrawerOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -107,7 +105,7 @@ export function TabsInfoPopup() {
|
|||||||
Introducing Tabs
|
Introducing Tabs
|
||||||
</p>
|
</p>
|
||||||
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
|
||||||
A faster, more intuitive way to work.
|
Organize your work and active sessions with tabs
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -116,7 +114,7 @@ export function TabsInfoPopup() {
|
|||||||
<DrawerContent>
|
<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">
|
<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">
|
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
||||||
June 16
|
July 14
|
||||||
</p>
|
</p>
|
||||||
<DrawerClose
|
<DrawerClose
|
||||||
as={IconButtonV2}
|
as={IconButtonV2}
|
||||||
@@ -127,16 +125,32 @@ export function TabsInfoPopup() {
|
|||||||
icon={<IconV2 name="xmark-small" />}
|
icon={<IconV2 name="xmark-small" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex w-full flex-col items-start gap-6 p-8">
|
<div class="relative flex min-h-0 w-full flex-1 flex-col items-start gap-6 overflow-y-auto p-8">
|
||||||
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
|
||||||
Introducing Tabs Navigation.
|
Introducing Tabs
|
||||||
</p>
|
|
||||||
<p class="w-full flex-1 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
|
||||||
We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at
|
|
||||||
the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch
|
|
||||||
contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the
|
|
||||||
sessions.
|
|
||||||
</p>
|
</p>
|
||||||
|
<div class="flex w-full flex-1 flex-col gap-4 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||||
|
<p>OpenCode Desktop is now built around tabs.</p>
|
||||||
|
<img src={tabsImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||||
|
<p>
|
||||||
|
Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when
|
||||||
|
you're starting something new, and close it when you're done.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something
|
||||||
|
memorable if you plan to keep them around.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.
|
||||||
|
</p>
|
||||||
|
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
|
||||||
|
<p>When you reopen the app, your tabs are still open.</p>
|
||||||
|
<p>
|
||||||
|
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
|
||||||
|
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
|
||||||
|
will become permanent in a few weeks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -633,7 +633,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
savedCursor = currentCursor()
|
const cursor = currentCursor()
|
||||||
|
savedCursor = cursor
|
||||||
|
if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor)
|
||||||
closePopover()
|
closePopover()
|
||||||
setComposing(false)
|
setComposing(false)
|
||||||
}
|
}
|
||||||
@@ -2201,10 +2203,7 @@ type ComposerModelControlState = {
|
|||||||
|
|
||||||
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||||
return (
|
return (
|
||||||
<div class="relative">
|
<div>
|
||||||
<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
|
<TooltipV2
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
@@ -2215,17 +2214,32 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Select
|
<MenuV2 gutter={6} modal={false} placement="top-start">
|
||||||
size="normal"
|
<MenuV2.Trigger
|
||||||
options={props.state.options}
|
as={ButtonV2}
|
||||||
current={props.state.current}
|
data-action="prompt-agent"
|
||||||
onSelect={props.state.onSelect}
|
variant="ghost-muted"
|
||||||
class="max-w-[175px] justify-start text-v2-text-text-faint [&_[data-component=icon]]:text-v2-icon-icon-muted"
|
size="normal"
|
||||||
valueClass="truncate pl-5 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
|
class="max-w-[175px] justify-start ![font-weight:440]"
|
||||||
triggerStyle={props.state.style}
|
style={props.state.style}
|
||||||
triggerProps={{ "data-action": "prompt-agent" }}
|
>
|
||||||
variant="ghost"
|
<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>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
|||||||
|
|
||||||
const createdClients: string[] = []
|
const createdClients: string[] = []
|
||||||
const createdSessions: string[] = []
|
const createdSessions: string[] = []
|
||||||
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||||
const optimistic: Array<{
|
const optimistic: Array<{
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -27,6 +27,8 @@ let params: { id?: string } = {}
|
|||||||
let search: { draftId?: string } = {}
|
let search: { draftId?: string } = {}
|
||||||
let selected = "/repo/worktree-a"
|
let selected = "/repo/worktree-a"
|
||||||
let variant: string | undefined
|
let variant: string | undefined
|
||||||
|
let permissionServer = "server-a"
|
||||||
|
let createSessionGate: Promise<void> | undefined
|
||||||
|
|
||||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
const prompt = {
|
const prompt = {
|
||||||
@@ -56,6 +58,7 @@ const clientFor = (directory: string) => {
|
|||||||
return {
|
return {
|
||||||
session: {
|
session: {
|
||||||
create: async () => {
|
create: async () => {
|
||||||
|
await createSessionGate
|
||||||
createdSessions.push(directory)
|
createdSessions.push(directory)
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
@@ -122,13 +125,14 @@ beforeAll(async () => {
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/permission", () => ({
|
mock.module("@/context/permission", () => {
|
||||||
usePermission: () => ({
|
const state = (server: string) => ({
|
||||||
enableAutoAccept(sessionID: string, directory: string) {
|
enableAutoAccept(sessionID: string, directory: string) {
|
||||||
enabledAutoAccept.push({ sessionID, directory })
|
enabledAutoAccept.push({ server, sessionID, directory })
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
}))
|
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
|
||||||
|
})
|
||||||
|
|
||||||
mock.module("@/context/server", () => ({
|
mock.module("@/context/server", () => ({
|
||||||
useServer: () => ({ key: "server-key" }),
|
useServer: () => ({ key: "server-key" }),
|
||||||
@@ -251,6 +255,8 @@ beforeEach(() => {
|
|||||||
syncedDirectories.length = 0
|
syncedDirectories.length = 0
|
||||||
selected = "/repo/worktree-a"
|
selected = "/repo/worktree-a"
|
||||||
variant = undefined
|
variant = undefined
|
||||||
|
permissionServer = "server-a"
|
||||||
|
createSessionGate = undefined
|
||||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -318,7 +324,40 @@ describe("prompt submit worktree selection", () => {
|
|||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
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" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("promotes drafts using the selected project's server", async () => {
|
test("promotes drafts using the selected project's server", async () => {
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
input.resetHistoryNavigation()
|
input.resetHistoryNavigation()
|
||||||
|
|
||||||
const projectDirectory = sdk().directory
|
const projectDirectory = sdk().directory
|
||||||
|
const permissionState = permission.currentServerState()
|
||||||
const isNewSession = !params.id
|
const isNewSession = !params.id
|
||||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||||
@@ -378,7 +379,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
session = created
|
session = created
|
||||||
await startTransition(() => {
|
await startTransition(() => {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||||
local.session.promote(sessionDirectory, session.id, {
|
local.session.promote(sessionDirectory, session.id, {
|
||||||
agent: currentAgent.name,
|
agent: currentAgent.name,
|
||||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { createEffect, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
|
import {
|
||||||
|
createEffect,
|
||||||
|
createSignal,
|
||||||
|
For,
|
||||||
|
onCleanup,
|
||||||
|
Show,
|
||||||
|
splitProps,
|
||||||
|
type Accessor,
|
||||||
|
type ComponentProps,
|
||||||
|
} from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -184,9 +193,28 @@ export function PromptProjectSelector(props: {
|
|||||||
controller: PromptProjectController
|
controller: PromptProjectController
|
||||||
placement?: "bottom" | "bottom-start"
|
placement?: "bottom" | "bottom-start"
|
||||||
}) {
|
}) {
|
||||||
|
const [triggerReady, setTriggerReady] = createSignal(false)
|
||||||
let contentRef: HTMLDivElement | undefined
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
let triggerFrame: number | undefined
|
||||||
let restoreTrigger = true
|
let restoreTrigger = true
|
||||||
|
|
||||||
|
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
|
||||||
|
const setTriggerRef = (element: HTMLButtonElement) => {
|
||||||
|
const ready = () => {
|
||||||
|
if (!element.isConnected) {
|
||||||
|
triggerFrame = requestAnimationFrame(ready)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
triggerFrame = undefined
|
||||||
|
setTriggerReady(true)
|
||||||
|
}
|
||||||
|
ready()
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame)
|
||||||
|
})
|
||||||
|
|
||||||
const activeItem = () =>
|
const activeItem = () =>
|
||||||
props.controller.active()
|
props.controller.active()
|
||||||
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
|
||||||
@@ -257,13 +285,13 @@ export function PromptProjectSelector(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
open={props.controller.open()}
|
open={triggerReady() && props.controller.open()}
|
||||||
placement={props.placement ?? "bottom"}
|
placement={props.placement ?? "bottom"}
|
||||||
gutter={4}
|
gutter={4}
|
||||||
modal={false}
|
modal={false}
|
||||||
onOpenChange={(open) => props.controller.setOpen(open)}
|
onOpenChange={(open) => props.controller.setOpen(open)}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Trigger as={ProjectTrigger} controller={props.controller} />
|
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
ref={contentRef}
|
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 data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
|
||||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||||
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
|
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
||||||
<div class="mt-8">{props.children}</div>
|
<div class="mt-8">{props.children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,9 +65,12 @@ export function SortableTerminalTabV2(props: {
|
|||||||
|
|
||||||
const focus = () => {
|
const focus = () => {
|
||||||
if (store.editing) return
|
if (store.editing) return
|
||||||
|
terminal.requestFocus(props.terminal.id)
|
||||||
terminal.open(props.terminal.id)
|
terminal.open(props.terminal.id)
|
||||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||||
focusTerminalById(props.terminal.id)
|
focusTerminalById(props.terminal.id)
|
||||||
|
const input = document.getElementById(`terminal-wrapper-${props.terminal.id}`)?.querySelector("textarea")
|
||||||
|
if (input === document.activeElement) terminal.consumeFocus(props.terminal.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const edit = (e?: Event) => {
|
const edit = (e?: Event) => {
|
||||||
|
|||||||
@@ -379,7 +379,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
</SettingsList>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -371,7 +371,7 @@
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-v2-tab-body.settings-v2-models {
|
.settings-v2-models {
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
||||||
|
|
||||||
|
describe("serverStatusDotClass", () => {
|
||||||
|
test("uses the success token while the server and services are healthy", () => {
|
||||||
|
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the warning token for non-blocking issues while the server is online", () => {
|
||||||
|
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the critical token only after the server connection drops", () => {
|
||||||
|
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: false })).toBe("bg-icon-critical-base")
|
||||||
|
expect(serverStatusDotClass({ ready: true, serverHealth: false, issue: true })).toBe("bg-icon-critical-base")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stays neutral before status is ready", () => {
|
||||||
|
expect(serverStatusDotClass({ ready: false, serverHealth: true, issue: false })).toBe("bg-border-weak-base")
|
||||||
|
expect(serverStatusDotClass({ ready: false, serverHealth: undefined, issue: false })).toBe("bg-border-weak-base")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("hasNonBlockingServiceIssue", () => {
|
||||||
|
test("detects MCP failures that do not block chatting", () => {
|
||||||
|
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
||||||
|
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
||||||
|
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
|
||||||
|
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects LSP failures that do not block chatting", () => {
|
||||||
|
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["error"] })).toBe(true)
|
||||||
|
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
export function hasNonBlockingServiceIssue(input: {
|
||||||
|
mcp: Array<McpStatus["status"]>
|
||||||
|
lsp: Array<LspStatus["status"]>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
|
||||||
|
input.lsp.some((status) => status === "error")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
|
||||||
|
if (input.serverHealth === false) return "bg-icon-critical-base"
|
||||||
|
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
|
||||||
|
if (input.issue) return "bg-icon-warning-base"
|
||||||
|
if (input.serverHealth === true) return "bg-icon-success-base"
|
||||||
|
return "bg-border-weak-base"
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { ServerConnection, useServer } from "@/context/server"
|
|||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
|
||||||
|
|
||||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||||
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
|
||||||
@@ -19,16 +20,14 @@ export function StatusPopover() {
|
|||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const ready = createMemo(() => global.servers.health[server.key]?.healthy === false || sync().data.mcp_ready)
|
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||||
const mcpIssue = createMemo(() => {
|
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
const mcp = Object.values(sync().data.mcp ?? {})
|
const issue = createMemo(() =>
|
||||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
hasNonBlockingServiceIssue({
|
||||||
const warn = mcp.some((item) => item.status === "needs_auth")
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
if (failed) return "critical" as const
|
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||||
if (warn) return "warning" as const
|
}),
|
||||||
})
|
)
|
||||||
const serverHealthy = () => global.servers.health[server.key]?.healthy === true
|
|
||||||
const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
@@ -47,13 +46,11 @@ export function StatusPopover() {
|
|||||||
<Icon name={shown() ? "status-active" : "status"} size="small" />
|
<Icon name={shown() ? "status-active" : "status"} size="small" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
classList={{
|
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
|
||||||
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
ready: ready(),
|
||||||
"bg-icon-success-base": ready() && healthy(),
|
serverHealth: serverHealth(),
|
||||||
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
|
issue: issue(),
|
||||||
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
|
})}`}
|
||||||
"bg-border-weak-base": serverHealthy() || !ready(),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -87,21 +84,18 @@ function DirectoryStatusPopover() {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
|
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
|
||||||
const ready = createMemo(() => serverHealth() === false || sync().data.mcp_ready)
|
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||||
const mcpIssue = createMemo(() => {
|
const issue = createMemo(() =>
|
||||||
const mcp = Object.values(sync().data.mcp ?? {})
|
hasNonBlockingServiceIssue({
|
||||||
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
|
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||||
const warn = mcp.some((item) => item.status === "needs_auth")
|
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||||
if (failed) return "critical" as const
|
}),
|
||||||
if (warn) return "warning" as const
|
)
|
||||||
})
|
|
||||||
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
|
|
||||||
const state = createMemo<StatusPopoverState>(() => ({
|
const state = createMemo<StatusPopoverState>(() => ({
|
||||||
shown: shown(),
|
shown: shown(),
|
||||||
ready: ready(),
|
ready: ready(),
|
||||||
healthy: healthy(),
|
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
issue: mcpIssue(),
|
issue: issue(),
|
||||||
label: language.t("status.popover.trigger"),
|
label: language.t("status.popover.trigger"),
|
||||||
onOpenChange: setShown,
|
onOpenChange: setShown,
|
||||||
body: () => (
|
body: () => (
|
||||||
@@ -123,8 +117,8 @@ function ServerStatusPopover() {
|
|||||||
const state = createMemo<StatusPopoverState>(() => ({
|
const state = createMemo<StatusPopoverState>(() => ({
|
||||||
shown: shown(),
|
shown: shown(),
|
||||||
ready: serverHealth() !== undefined,
|
ready: serverHealth() !== undefined,
|
||||||
healthy: serverHealth() === true,
|
|
||||||
serverHealth: serverHealth(),
|
serverHealth: serverHealth(),
|
||||||
|
issue: false,
|
||||||
label: language.t("status.popover.trigger"),
|
label: language.t("status.popover.trigger"),
|
||||||
onOpenChange: setShown,
|
onOpenChange: setShown,
|
||||||
body: () => (
|
body: () => (
|
||||||
@@ -140,9 +134,8 @@ function ServerStatusPopover() {
|
|||||||
type StatusPopoverState = {
|
type StatusPopoverState = {
|
||||||
shown: boolean
|
shown: boolean
|
||||||
ready: boolean
|
ready: boolean
|
||||||
healthy: boolean
|
|
||||||
serverHealth: boolean | undefined
|
serverHealth: boolean | undefined
|
||||||
issue?: "critical" | "warning"
|
issue: boolean
|
||||||
label: string
|
label: string
|
||||||
onOpenChange: (value: boolean) => void
|
onOpenChange: (value: boolean) => void
|
||||||
body: () => JSX.Element
|
body: () => JSX.Element
|
||||||
@@ -161,16 +154,6 @@ function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatusPopoverView(props: { state: StatusPopoverState }) {
|
function StatusPopoverView(props: { state: StatusPopoverState }) {
|
||||||
const statusDotClass = () => ({
|
|
||||||
"absolute rounded-full": true,
|
|
||||||
"bg-icon-success-base": props.state.ready && props.state.healthy,
|
|
||||||
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
|
|
||||||
"bg-icon-critical-base":
|
|
||||||
props.state.serverHealth === false ||
|
|
||||||
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
|
|
||||||
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
|
|
||||||
})
|
|
||||||
|
|
||||||
const popoverProps = {
|
const popoverProps = {
|
||||||
class:
|
class:
|
||||||
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
|
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
|
||||||
@@ -195,8 +178,7 @@ function StatusPopoverView(props: { state: StatusPopoverState }) {
|
|||||||
<div class="relative size-4">
|
<div class="relative size-4">
|
||||||
<IconV2 name={props.state.shown ? "status-active" : "status"} />
|
<IconV2 name={props.state.shown ? "status-active" : "status"} />
|
||||||
<div
|
<div
|
||||||
classList={statusDotClass()}
|
class={`absolute -top-1 -right-1 size-2 rounded-full border border-[var(--v2-background-bg-deep)] ${serverStatusDotClass(props.state)}`}
|
||||||
class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
|||||||
export interface TerminalProps extends ComponentProps<"div"> {
|
export interface TerminalProps extends ComponentProps<"div"> {
|
||||||
pty: LocalPTY
|
pty: LocalPTY
|
||||||
autoFocus?: boolean
|
autoFocus?: boolean
|
||||||
|
onAutoFocus?: () => void
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
||||||
onConnect?: () => void
|
onConnect?: () => void
|
||||||
@@ -185,7 +186,15 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const authToken = connection.type === "http" ? connection.authToken : false
|
const authToken = connection.type === "http" ? connection.authToken : false
|
||||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
const [local, others] = splitProps(props, [
|
||||||
|
"pty",
|
||||||
|
"class",
|
||||||
|
"classList",
|
||||||
|
"autoFocus",
|
||||||
|
"onAutoFocus",
|
||||||
|
"onConnect",
|
||||||
|
"onConnectError",
|
||||||
|
])
|
||||||
const id = local.pty.id
|
const id = local.pty.id
|
||||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||||
const restoreSize =
|
const restoreSize =
|
||||||
@@ -416,6 +425,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
fitAddon = fit
|
fitAddon = fit
|
||||||
serializeAddon = serializer
|
serializeAddon = serializer
|
||||||
|
|
||||||
|
const active = document.activeElement
|
||||||
t.open(container)
|
t.open(container)
|
||||||
useTerminalUiBindings({
|
useTerminalUiBindings({
|
||||||
container,
|
container,
|
||||||
@@ -425,7 +435,22 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
handleLinkClick,
|
handleLinkClick,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (local.autoFocus !== false) focusTerminal()
|
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 (typeof document !== "undefined" && document.fonts) {
|
if (typeof document !== "undefined" && document.fonts) {
|
||||||
void document.fonts.ready.then(scheduleFit)
|
void document.fonts.ready.then(scheduleFit)
|
||||||
|
|||||||
@@ -221,4 +221,56 @@ describe("createChildStoreManager", () => {
|
|||||||
dispose()
|
dispose()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps non-bootstrapping children passive until a real directory access", () => {
|
||||||
|
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||||
|
const offset = querySingles.length
|
||||||
|
const bootstraps: string[] = []
|
||||||
|
|
||||||
|
const dispose = createOwner((owner) => {
|
||||||
|
manager = createChildStoreManager({
|
||||||
|
owner,
|
||||||
|
scope: ServerScope.local,
|
||||||
|
persist,
|
||||||
|
isBooting: () => false,
|
||||||
|
isLoadingSessions: () => false,
|
||||||
|
onBootstrap(directory) {
|
||||||
|
bootstraps.push(directory)
|
||||||
|
},
|
||||||
|
onMcp() {},
|
||||||
|
onDispose() {},
|
||||||
|
translate: (key) => key,
|
||||||
|
queryOptions: queryOptionsApi,
|
||||||
|
global: { provider },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!manager) throw new Error("manager required")
|
||||||
|
const [store] = manager.child("/project", { bootstrap: false })
|
||||||
|
const queries = querySingles.slice(offset)
|
||||||
|
|
||||||
|
expect(queries).toHaveLength(6)
|
||||||
|
expect(queries[0]?.().enabled).toBe(false)
|
||||||
|
expect(queries[3]?.().enabled).toBe(false)
|
||||||
|
expect(queries[4]?.().enabled).toBe(false)
|
||||||
|
expect(queries[5]?.().enabled).toBe(false)
|
||||||
|
expect(store.path.directory).toBe("/project")
|
||||||
|
expect(store.provider_ready).toBe(false)
|
||||||
|
expect(store.lsp_ready).toBe(false)
|
||||||
|
expect(bootstraps).toEqual([])
|
||||||
|
|
||||||
|
manager.child("/project")
|
||||||
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
|
expect(queries[3]?.().enabled).toBe(true)
|
||||||
|
expect(queries[4]?.().enabled).toBe(true)
|
||||||
|
expect(queries[5]?.().enabled).toBe(true)
|
||||||
|
expect(bootstraps).toEqual(["/project"])
|
||||||
|
|
||||||
|
manager.child("/project", { bootstrap: false })
|
||||||
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
|
} finally {
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export function createChildStoreManager(input: {
|
|||||||
const disposers = new Map<string, () => void>()
|
const disposers = new Map<string, () => void>()
|
||||||
const mcpDirectories = new Set<string>()
|
const mcpDirectories = new Set<string>()
|
||||||
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
|
const activeDirectories = new Set<string>()
|
||||||
|
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
|
|
||||||
const markKey = (key: DirectoryKey) => {
|
const markKey = (key: DirectoryKey) => {
|
||||||
if (!key) return
|
if (!key) return
|
||||||
@@ -118,6 +120,8 @@ export function createChildStoreManager(input: {
|
|||||||
lifecycle.delete(key)
|
lifecycle.delete(key)
|
||||||
mcpDirectories.delete(key)
|
mcpDirectories.delete(key)
|
||||||
mcpToggles.delete(key)
|
mcpToggles.delete(key)
|
||||||
|
activeDirectories.delete(key)
|
||||||
|
activationToggles.delete(key)
|
||||||
const dispose = disposers.get(key)
|
const dispose = disposers.get(key)
|
||||||
if (dispose) {
|
if (dispose) {
|
||||||
dispose()
|
dispose()
|
||||||
@@ -182,20 +186,27 @@ export function createChildStoreManager(input: {
|
|||||||
const initialMeta = meta[0].value
|
const initialMeta = meta[0].value
|
||||||
const initialIcon = icon[0].value
|
const initialIcon = icon[0].value
|
||||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||||
|
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||||
|
|
||||||
const pathQuery = useQuery(() => input.queryOptions.path(key))
|
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
||||||
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||||
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
||||||
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
|
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
||||||
const providerQuery = useQuery(() => input.queryOptions.providers(key))
|
const providerQuery = useQuery(() => ({
|
||||||
const referenceQuery = useQuery(() => input.queryOptions.references(key))
|
...input.queryOptions.providers(key),
|
||||||
|
enabled: instanceQueriesEnabled(),
|
||||||
|
}))
|
||||||
|
const referenceQuery = useQuery(() => ({
|
||||||
|
...input.queryOptions.references(key),
|
||||||
|
enabled: instanceQueriesEnabled(),
|
||||||
|
}))
|
||||||
|
|
||||||
const child = createStore<State>({
|
const child = createStore<State>({
|
||||||
project: "",
|
project: "",
|
||||||
projectMeta: initialMeta,
|
projectMeta: initialMeta,
|
||||||
icon: initialIcon,
|
icon: initialIcon,
|
||||||
get provider_ready() {
|
get provider_ready() {
|
||||||
return !providerQuery.isLoading
|
return instanceQueriesEnabled() && !providerQuery.isLoading
|
||||||
},
|
},
|
||||||
get provider() {
|
get provider() {
|
||||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||||
@@ -236,7 +247,7 @@ export function createChildStoreManager(input: {
|
|||||||
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
|
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
|
||||||
},
|
},
|
||||||
get lsp_ready() {
|
get lsp_ready() {
|
||||||
return !lspQuery.isLoading
|
return instanceQueriesEnabled() && !lspQuery.isLoading
|
||||||
},
|
},
|
||||||
get lsp() {
|
get lsp() {
|
||||||
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
||||||
@@ -250,6 +261,7 @@ export function createChildStoreManager(input: {
|
|||||||
children[key] = child
|
children[key] = child
|
||||||
disposers.set(key, dispose)
|
disposers.set(key, dispose)
|
||||||
mcpToggles.set(key, setMcpEnabled)
|
mcpToggles.set(key, setMcpEnabled)
|
||||||
|
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||||
|
|
||||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||||
if (!(init instanceof Promise)) return
|
if (!(init instanceof Promise)) return
|
||||||
@@ -290,6 +302,7 @@ export function createChildStoreManager(input: {
|
|||||||
pinForOwner(key)
|
pinForOwner(key)
|
||||||
if (options.mcp) enableMcp(directory, key, childStore)
|
if (options.mcp) enableMcp(directory, key, childStore)
|
||||||
const shouldBootstrap = options.bootstrap ?? true
|
const shouldBootstrap = options.bootstrap ?? true
|
||||||
|
if (shouldBootstrap) activate(key)
|
||||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||||
input.onBootstrap(directory)
|
input.onBootstrap(directory)
|
||||||
}
|
}
|
||||||
@@ -301,6 +314,7 @@ export function createChildStoreManager(input: {
|
|||||||
const childStore = ensureChild(directory)
|
const childStore = ensureChild(directory)
|
||||||
if (options.mcp) enableMcp(directory, key, childStore)
|
if (options.mcp) enableMcp(directory, key, childStore)
|
||||||
const shouldBootstrap = options.bootstrap ?? true
|
const shouldBootstrap = options.bootstrap ?? true
|
||||||
|
if (shouldBootstrap) activate(key)
|
||||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||||
input.onBootstrap(directory)
|
input.onBootstrap(directory)
|
||||||
}
|
}
|
||||||
@@ -314,6 +328,16 @@ export function createChildStoreManager(input: {
|
|||||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Passive Home/project metadata reads must not initialize the directory.
|
||||||
|
// A real directory access enables these queries once for the store lifetime.
|
||||||
|
// TODO(v2): After Home switches to v2.project.list and root-filtered,
|
||||||
|
// updated-time v2.session.list, remove any Home-only passive child creation.
|
||||||
|
function activate(key: DirectoryKey) {
|
||||||
|
if (activeDirectories.has(key)) return
|
||||||
|
activeDirectories.add(key)
|
||||||
|
activationToggles.get(key)?.(true)
|
||||||
|
}
|
||||||
|
|
||||||
function disableMcp(directory: string) {
|
function disableMcp(directory: string) {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
if (!mcpDirectories.delete(key)) return
|
if (!mcpDirectories.delete(key)) return
|
||||||
@@ -360,6 +384,7 @@ export function createChildStoreManager(input: {
|
|||||||
unpin,
|
unpin,
|
||||||
pinned,
|
pinned,
|
||||||
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
||||||
|
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
|
||||||
disableMcp,
|
disableMcp,
|
||||||
disposeDirectory,
|
disposeDirectory,
|
||||||
runEviction,
|
runEviction,
|
||||||
|
|||||||
@@ -243,6 +243,22 @@ describe("applyDirectoryEvent", () => {
|
|||||||
expect(store.session_status.ses_1).toBeUndefined()
|
expect(store.session_status.ses_1).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("ignores an archived session absent from a passive directory store", () => {
|
||||||
|
const [store, setStore] = createStore(baseState({ session: [], sessionTotal: 0 }))
|
||||||
|
|
||||||
|
applyDirectoryEvent({
|
||||||
|
event: { type: "session.updated", properties: { info: rootSession({ id: "missing", archived: 10 }) } },
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
push() {},
|
||||||
|
directory: "/tmp",
|
||||||
|
loadLsp() {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.session).toEqual([])
|
||||||
|
expect(store.sessionTotal).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
test("cleans session caches when deleted and decrements only root totals", () => {
|
test("cleans session caches when deleted and decrements only root totals", () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
|
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
|
||||||
|
|||||||
@@ -146,15 +146,14 @@ export function applyDirectoryEvent(input: {
|
|||||||
const info = (event.properties as { info: Session }).info
|
const info = (event.properties as { info: Session }).info
|
||||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||||
if (info.time.archived) {
|
if (info.time.archived) {
|
||||||
|
if (!result.found) break
|
||||||
if (input.store.session[result.index]!.time.archived === info.time.archived) break
|
if (input.store.session[result.index]!.time.archived === info.time.archived) break
|
||||||
if (result.found) {
|
input.setStore(
|
||||||
input.setStore(
|
"session",
|
||||||
"session",
|
produce((draft) => {
|
||||||
produce((draft) => {
|
draft.splice(result.index, 1)
|
||||||
draft.splice(result.index, 1)
|
}),
|
||||||
}),
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||||
if (info.parentID) break
|
if (info.parentID) break
|
||||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
applyHomeSessionEvent,
|
||||||
|
appendHomeSessionEvent,
|
||||||
|
HOME_V2_SESSION_PAGE_LIMIT,
|
||||||
|
loadHomeSessionIndex,
|
||||||
|
homeSessionIndexSessions,
|
||||||
|
homeSessionIndexRefresh,
|
||||||
|
parseHomeSessionIndex,
|
||||||
|
retainHomeSessions,
|
||||||
|
} from "./home-session-index"
|
||||||
|
|
||||||
|
const session = (input: {
|
||||||
|
id: string
|
||||||
|
directory?: string
|
||||||
|
parentID?: string
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
}) => ({
|
||||||
|
id: input.id,
|
||||||
|
parentID: input.parentID,
|
||||||
|
projectID: "project",
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 1, updated: input.updated ?? 1, archived: input.archived },
|
||||||
|
title: input.id,
|
||||||
|
location: { directory: input.directory ?? "/project" },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Home V2 session index", () => {
|
||||||
|
test("loads the Home index with one global V2 request", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const result = await loadHomeSessionIndex(async (input) => {
|
||||||
|
calls.push(input)
|
||||||
|
return { data: { data: [session({ id: "root" })], cursor: {} } }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.sessions).toHaveLength(1)
|
||||||
|
expect(calls).toEqual([{ limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads subsequent pages until the session index is complete", async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const controller = new AbortController()
|
||||||
|
const result = await loadHomeSessionIndex(
|
||||||
|
async (input, options) => {
|
||||||
|
calls.push({ input, signal: options.signal })
|
||||||
|
if (!("cursor" in input)) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
data: Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) =>
|
||||||
|
session({ id: `page-1-${index}` }),
|
||||||
|
),
|
||||||
|
cursor: { next: "next-page" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { data: { data: [session({ id: "page-2" })], cursor: {} } }
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
controller.signal,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.sessions).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{ input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc" }, signal: controller.signal },
|
||||||
|
{
|
||||||
|
input: { limit: HOME_V2_SESSION_PAGE_LIMIT, order: "desc", cursor: "next-page" },
|
||||||
|
signal: controller.signal,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("maps visible roots to Home session summaries", () => {
|
||||||
|
const result = parseHomeSessionIndex([
|
||||||
|
session({ id: "root", updated: 30 }),
|
||||||
|
session({ id: "child", parentID: "root", updated: 40 }),
|
||||||
|
session({ id: "archived", archived: 50, updated: 50 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "root",
|
||||||
|
slug: "root",
|
||||||
|
version: "",
|
||||||
|
directory: "/project",
|
||||||
|
projectID: "project",
|
||||||
|
title: "root",
|
||||||
|
time: { created: 1, updated: 30 },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves the per-directory Home retention limit", () => {
|
||||||
|
const now = 10 * 60 * 60 * 1000
|
||||||
|
const sessions = Array.from({ length: 80 }, (_, index) => ({
|
||||||
|
...parseHomeSessionIndex([session({ id: `session-${index}`, updated: index + 1 })])[0],
|
||||||
|
directory: index % 2 === 0 ? "/one" : "/two",
|
||||||
|
}))
|
||||||
|
|
||||||
|
const retained = retainHomeSessions(sessions, 10, now)
|
||||||
|
expect(retained.filter((item) => item.directory === "/one")).toHaveLength(10)
|
||||||
|
expect(retained.filter((item) => item.directory === "/two")).toHaveLength(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replays session events over the loaded index", () => {
|
||||||
|
const initial = parseHomeSessionIndex([session({ id: "old" })])
|
||||||
|
const created = { ...initial[0], id: "new", slug: "new", title: "new", time: { created: 2, updated: 2 } }
|
||||||
|
|
||||||
|
const afterCreate = applyHomeSessionEvent(initial, {
|
||||||
|
type: "session.created",
|
||||||
|
properties: { sessionID: created.id, info: created },
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
applyHomeSessionEvent(afterCreate, {
|
||||||
|
type: "session.deleted",
|
||||||
|
properties: { sessionID: initial[0]!.id, info: initial[0]! },
|
||||||
|
}),
|
||||||
|
).toEqual([created])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("applies only events newer than the index baseline", () => {
|
||||||
|
const initial = parseHomeSessionIndex([session({ id: "old" })])
|
||||||
|
const stale = { ...initial[0], title: "stale" }
|
||||||
|
const current = { ...initial[0], title: "current" }
|
||||||
|
const first = appendHomeSessionEvent(undefined, {
|
||||||
|
type: "session.updated",
|
||||||
|
properties: { sessionID: stale.id, info: stale },
|
||||||
|
})
|
||||||
|
const events = appendHomeSessionEvent(first, {
|
||||||
|
type: "session.updated",
|
||||||
|
properties: { sessionID: current.id, info: current },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(homeSessionIndexSessions({ sessions: initial, eventSequence: 1 }, events)[0]?.title).toBe("current")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refetches after reconnect, disposal, and session moves", () => {
|
||||||
|
expect(homeSessionIndexRefresh("server.connected", false)).toEqual({ connected: true, refetch: false })
|
||||||
|
expect(homeSessionIndexRefresh("server.connected", true)).toEqual({ connected: true, refetch: true })
|
||||||
|
expect(homeSessionIndexRefresh("global.disposed", true).refetch).toBe(true)
|
||||||
|
expect(homeSessionIndexRefresh("session.next.moved", true).refetch).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { QueryClient } from "@tanstack/solid-query"
|
||||||
|
import { trimSessions } from "./session-trim"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
||||||
|
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
|
||||||
|
|
||||||
|
export type HomeSessionEvent = {
|
||||||
|
type: "session.created" | "session.updated" | "session.deleted"
|
||||||
|
properties: { sessionID: string; info: Session }
|
||||||
|
}
|
||||||
|
export type HomeSessionEvents = {
|
||||||
|
sequence: number
|
||||||
|
entries: Array<{ sequence: number; event: HomeSessionEvent }>
|
||||||
|
}
|
||||||
|
export type HomeSessionIndex = {
|
||||||
|
sessions: Session[]
|
||||||
|
eventSequence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const
|
||||||
|
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
||||||
|
|
||||||
|
type HomeSessionPage = { data?: V2SessionListResponse }
|
||||||
|
|
||||||
|
export async function loadHomeSessionIndex(
|
||||||
|
list: (
|
||||||
|
input: { limit: number; order: "desc"; cursor?: string },
|
||||||
|
options: { signal?: AbortSignal },
|
||||||
|
) => Promise<HomeSessionPage>,
|
||||||
|
eventSequence = 0,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const data: SessionV2Info[] = []
|
||||||
|
let cursor: string | undefined
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const response = await list(
|
||||||
|
{
|
||||||
|
limit: HOME_V2_SESSION_PAGE_LIMIT,
|
||||||
|
order: "desc",
|
||||||
|
...(cursor ? { cursor } : {}),
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
)
|
||||||
|
const page = response.data!
|
||||||
|
data.push(...page.data)
|
||||||
|
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
|
||||||
|
return { sessions: parseHomeSessionIndex(data), eventSequence }
|
||||||
|
cursor = page.cursor.next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) {
|
||||||
|
const sequence = (current?.sequence ?? 0) + 1
|
||||||
|
return {
|
||||||
|
sequence,
|
||||||
|
entries: [...(current?.entries ?? []), { sequence, event }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents {
|
||||||
|
return {
|
||||||
|
sequence: current?.sequence ?? sequence,
|
||||||
|
entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
|
||||||
|
if (!index) return []
|
||||||
|
return (events?.entries ?? [])
|
||||||
|
.filter((entry) => entry.sequence > index.eventSequence)
|
||||||
|
.reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) {
|
||||||
|
if (event === "server.connected") return { connected: true, refetch: connected }
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
refetch: event === "global.disposed" || event === "session.next.moved",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) {
|
||||||
|
const indexKey = homeSessionIndexKey(server)
|
||||||
|
const eventsKey = homeSessionEventsKey(server)
|
||||||
|
let connected = false
|
||||||
|
|
||||||
|
return {
|
||||||
|
indexKey,
|
||||||
|
eventsKey,
|
||||||
|
eventSequence() {
|
||||||
|
return queryClient.getQueryData<HomeSessionEvents>(eventsKey)?.sequence ?? 0
|
||||||
|
},
|
||||||
|
complete(sequence: number) {
|
||||||
|
// Keep events received after the fetch began so its response cannot overwrite them.
|
||||||
|
queryClient.setQueryData<HomeSessionEvents>(eventsKey, (current) => trimHomeSessionEvents(current, sequence))
|
||||||
|
},
|
||||||
|
sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) {
|
||||||
|
return homeSessionIndexSessions(index, events)
|
||||||
|
},
|
||||||
|
apply(event: HomeSessionEvent) {
|
||||||
|
if (!queryClient.getQueryState(indexKey)) return
|
||||||
|
const next = appendHomeSessionEvent(queryClient.getQueryData<HomeSessionEvents>(eventsKey), event)
|
||||||
|
if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) {
|
||||||
|
queryClient.setQueryData(eventsKey, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = queryClient.getQueryData<HomeSessionIndex>(indexKey)
|
||||||
|
if (index) {
|
||||||
|
queryClient.setQueryData<HomeSessionIndex>(indexKey, {
|
||||||
|
sessions: homeSessionIndexSessions(index, next),
|
||||||
|
eventSequence: next.sequence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
queryClient.setQueryData<HomeSessionEvents>(eventsKey, { sequence: next.sequence, entries: [] })
|
||||||
|
},
|
||||||
|
refresh(event: Event["type"]) {
|
||||||
|
const result = homeSessionIndexRefresh(event, connected)
|
||||||
|
connected = result.connected
|
||||||
|
if (!result.refetch) return
|
||||||
|
void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(v2): This deliberately dumb full-table scan is necessary because the
|
||||||
|
// current V2 API orders by creation time and cannot filter roots, archives, or
|
||||||
|
// multiple directories. A bounded page could omit an old session updated today.
|
||||||
|
// Once released, use client.v2.project.list() and client.v2.session.list({
|
||||||
|
// parentID: null, order: "desc" }), then remove this adapter and its V1 fields.
|
||||||
|
export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] {
|
||||||
|
return sessions.flatMap((item) => {
|
||||||
|
if (item.parentID || item.time.archived !== undefined) return []
|
||||||
|
return [toLegacySummary(item)]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retainHomeSessions(sessions: Session[], limit: number, now: number) {
|
||||||
|
const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory))
|
||||||
|
return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) {
|
||||||
|
const info = event.properties.info
|
||||||
|
const index = sessions.findIndex((session) => session.id === info.id)
|
||||||
|
if (event.type === "session.deleted" || info.parentID || info.time.archived !== undefined) {
|
||||||
|
if (index === -1) return sessions
|
||||||
|
return sessions.toSpliced(index, 1)
|
||||||
|
}
|
||||||
|
if (event.type !== "session.created" && event.type !== "session.updated") return sessions
|
||||||
|
if (index === -1) return [...sessions, info]
|
||||||
|
return sessions.with(index, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLegacySummary(session: SessionV2Info): Session {
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
slug: session.id,
|
||||||
|
projectID: session.projectID,
|
||||||
|
workspaceID: session.location.workspaceID,
|
||||||
|
directory: session.location.directory,
|
||||||
|
path: session.subpath,
|
||||||
|
parentID: session.parentID,
|
||||||
|
cost: session.cost,
|
||||||
|
tokens: session.tokens,
|
||||||
|
title: session.title,
|
||||||
|
agent: session.agent,
|
||||||
|
model: session.model,
|
||||||
|
version: "",
|
||||||
|
time: session.time,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
|
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
|
||||||
|
|
||||||
const session = (input: { id: string; parentID?: string }) =>
|
const session = (input: { id: string; parentID?: string }) =>
|
||||||
({
|
({
|
||||||
@@ -69,6 +69,7 @@ describe("autoRespondsPermission", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
|
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
|
||||||
|
expect(sessionAutoAccept(autoAccept, sessions, permission("root"), directory)).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session-level override takes precedence over directory-level", () => {
|
test("session-level override takes precedence over directory-level", () => {
|
||||||
@@ -81,6 +82,28 @@ describe("autoRespondsPermission", () => {
|
|||||||
|
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
|
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("parent false override takes precedence over directory-level auto-accept", () => {
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||||
|
const autoAccept = {
|
||||||
|
[`${base64Encode(directory)}/*`]: true,
|
||||||
|
[`${base64Encode(directory)}/root`]: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parent true override takes precedence over disabled directory fallback", () => {
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||||
|
const autoAccept = {
|
||||||
|
[`${base64Encode(directory)}/*`]: false,
|
||||||
|
[`${base64Encode(directory)}/root`]: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("isDirectoryAutoAccepting", () => {
|
describe("isDirectoryAutoAccepting", () => {
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ export function directoryAcceptKey(directory: string) {
|
|||||||
|
|
||||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||||
const key = acceptKey(sessionID, directory)
|
const key = acceptKey(sessionID, directory)
|
||||||
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
|
return autoAccept[key] ?? autoAccept[sessionID]
|
||||||
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
||||||
@@ -44,8 +43,18 @@ export function autoRespondsPermission(
|
|||||||
permission: { sessionID: string },
|
permission: { sessionID: string },
|
||||||
directory?: string,
|
directory?: string,
|
||||||
) {
|
) {
|
||||||
const value = sessionLineage(session, permission.sessionID)
|
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)
|
||||||
.map((id) => accepted(autoAccept, id, directory))
|
.map((id) => accepted(autoAccept, id, directory))
|
||||||
.find((item): item is boolean => item !== undefined)
|
.find((item): item is boolean => item !== undefined)
|
||||||
return value ?? false
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import type { ServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "./server-sync"
|
import type { ServerSync } from "./server-sync"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
import { useGlobal } from "./global"
|
||||||
|
import { ServerConnection, useServer } from "./server"
|
||||||
|
import { type DraftTab, useTabs } from "./tabs"
|
||||||
|
import { useSettings } from "./settings"
|
||||||
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import {
|
import {
|
||||||
acceptKey,
|
acceptKey,
|
||||||
directoryAcceptKey,
|
directoryAcceptKey,
|
||||||
isDirectoryAutoAccepting,
|
isDirectoryAutoAccepting,
|
||||||
autoRespondsPermission,
|
autoRespondsPermission,
|
||||||
|
sessionAutoAccept,
|
||||||
} from "./permission-auto-respond"
|
} from "./permission-auto-respond"
|
||||||
|
|
||||||
type PermissionRespondFn = (input: {
|
type PermissionRespondFn = (input: {
|
||||||
@@ -47,234 +54,417 @@ function hasPermissionPromptRules(permission: unknown) {
|
|||||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||||
name: "Permission",
|
name: "Permission",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: (props: { directory?: Accessor<string | undefined> }) => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
|
||||||
const serverSDK = useServerSDK()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const serverSync = useServerSync()
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
const permissionsEnabled = createMemo(() => {
|
const permissionsEnabled = createMemo(() => {
|
||||||
const directory = props.directory?.() ?? decode64(params.dir)
|
const directory = activeDirectory()
|
||||||
if (!directory) return false
|
if (!directory) return false
|
||||||
const [store] = serverSync().child(directory)
|
return selected().permissionsEnabled(directory)
|
||||||
return hasPermissionPromptRules(store.config.permission)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
|
||||||
{
|
|
||||||
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
|
|
||||||
migrate(value) {
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
|
||||||
|
|
||||||
const data = value as Record<string, unknown>
|
|
||||||
if (data.autoAccept) return value
|
|
||||||
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
autoAccept:
|
|
||||||
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
|
|
||||||
? data.autoAcceptEdits
|
|
||||||
: {},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
createStore({
|
|
||||||
autoAccept: {} as Record<string, boolean>,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// When config has permission: "allow", auto-enable directory-level auto-accept
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready()) return
|
|
||||||
const directory = props.directory?.() ?? decode64(params.dir)
|
|
||||||
if (!directory) return
|
|
||||||
const [childStore] = serverSync().child(directory)
|
|
||||||
const perm = childStore.config.permission
|
|
||||||
if (typeof perm === "string" && perm === "allow") {
|
|
||||||
const key = directoryAcceptKey(directory)
|
|
||||||
if (store.autoAccept[key] === undefined) {
|
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
draft.autoAccept[key] = true
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const MAX_RESPONDED = 1000
|
|
||||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
|
||||||
const responded = new Map<string, number>()
|
|
||||||
const enableVersion = new Map<string, number>()
|
|
||||||
|
|
||||||
function pruneResponded(now: number) {
|
|
||||||
for (const [id, ts] of responded) {
|
|
||||||
if (now - ts < RESPONDED_TTL_MS) break
|
|
||||||
responded.delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const id of responded.keys()) {
|
|
||||||
if (responded.size <= MAX_RESPONDED) break
|
|
||||||
responded.delete(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const respond: PermissionRespondFn = (input) => {
|
|
||||||
serverSDK()
|
|
||||||
.client.permission.respond(input)
|
|
||||||
.catch(() => {
|
|
||||||
responded.delete(input.permissionID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
|
||||||
const now = Date.now()
|
|
||||||
const hit = responded.has(permission.id)
|
|
||||||
responded.delete(permission.id)
|
|
||||||
responded.set(permission.id, now)
|
|
||||||
pruneResponded(now)
|
|
||||||
if (hit) return
|
|
||||||
respond({
|
|
||||||
sessionID: permission.sessionID,
|
|
||||||
permissionID: permission.id,
|
|
||||||
response: "once",
|
|
||||||
directory,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
|
||||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
|
||||||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAutoAcceptingDirectory(directory: string) {
|
|
||||||
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
|
||||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
|
||||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
function bumpEnableVersion(sessionID: string, directory?: string) {
|
|
||||||
const key = acceptKey(sessionID, directory)
|
|
||||||
const next = (enableVersion.get(key) ?? 0) + 1
|
|
||||||
enableVersion.set(key, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsubscribe = serverSDK().event.listen((e) => {
|
|
||||||
const event = e.details
|
|
||||||
if (event?.type !== "permission.asked") return
|
|
||||||
|
|
||||||
const perm = event.properties
|
|
||||||
if (!shouldAutoRespond(perm, e.name)) return
|
|
||||||
|
|
||||||
respondOnce(perm, e.name)
|
|
||||||
})
|
|
||||||
onCleanup(unsubscribe)
|
|
||||||
|
|
||||||
function enableDirectory(directory: string) {
|
|
||||||
const key = directoryAcceptKey(directory)
|
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
draft.autoAccept[key] = true
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
serverSDK()
|
|
||||||
.client.permission.list({ directory })
|
|
||||||
.then((x) => {
|
|
||||||
if (!isAutoAcceptingDirectory(directory)) return
|
|
||||||
for (const perm of x.data ?? []) {
|
|
||||||
if (!perm?.id) continue
|
|
||||||
if (!shouldAutoRespond(perm, directory)) continue
|
|
||||||
respondOnce(perm, directory)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
function disableDirectory(directory: string) {
|
|
||||||
const key = directoryAcceptKey(directory)
|
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
draft.autoAccept[key] = false
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function enable(sessionID: string, directory: string) {
|
|
||||||
const key = acceptKey(sessionID, directory)
|
|
||||||
const version = bumpEnableVersion(sessionID, directory)
|
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
draft.autoAccept[key] = true
|
|
||||||
delete draft.autoAccept[sessionID]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
serverSDK()
|
|
||||||
.client.permission.list({ directory })
|
|
||||||
.then((x) => {
|
|
||||||
if (enableVersion.get(key) !== version) return
|
|
||||||
if (!isAutoAccepting(sessionID, directory)) return
|
|
||||||
for (const perm of x.data ?? []) {
|
|
||||||
if (!perm?.id) continue
|
|
||||||
if (!shouldAutoRespond(perm, directory)) continue
|
|
||||||
respondOnce(perm, directory)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
function disable(sessionID: string, directory?: string) {
|
|
||||||
bumpEnableVersion(sessionID, directory)
|
|
||||||
const key = directory ? acceptKey(sessionID, directory) : sessionID
|
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
draft.autoAccept[key] = false
|
|
||||||
if (!directory) return
|
|
||||||
delete draft.autoAccept[sessionID]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready: () => selected().ready(),
|
||||||
respond,
|
ensureServerState: (key: ServerConnection.Key) => ensure(key).api,
|
||||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
currentServerState: () => selected().api,
|
||||||
return shouldAutoRespond(permission, directory)
|
respond(input: Parameters<PermissionRespondFn>[0]) {
|
||||||
|
selected().respond(input)
|
||||||
|
},
|
||||||
|
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)
|
||||||
},
|
},
|
||||||
isAutoAccepting,
|
|
||||||
isAutoAcceptingDirectory,
|
|
||||||
toggleAutoAccept(sessionID: string, directory: string) {
|
toggleAutoAccept(sessionID: string, directory: string) {
|
||||||
if (isAutoAccepting(sessionID, directory)) {
|
selected().toggleAutoAccept(sessionID, directory)
|
||||||
disable(sessionID, directory)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
enable(sessionID, directory)
|
|
||||||
},
|
},
|
||||||
toggleAutoAcceptDirectory(directory: string) {
|
toggleAutoAcceptDirectory(directory: string) {
|
||||||
if (isAutoAcceptingDirectory(directory)) {
|
selected().toggleAutoAcceptDirectory(directory)
|
||||||
disableDirectory(directory)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
enableDirectory(directory)
|
|
||||||
},
|
},
|
||||||
enableAutoAccept(sessionID: string, directory: string) {
|
enableAutoAccept(sessionID: string, directory: string) {
|
||||||
if (isAutoAccepting(sessionID, directory)) return
|
selected().enableAutoAccept(sessionID, directory)
|
||||||
enable(sessionID, directory)
|
|
||||||
},
|
},
|
||||||
disableAutoAccept(sessionID: string, directory?: string) {
|
disableAutoAccept(sessionID: string, directory?: string) {
|
||||||
disable(sessionID, directory)
|
selected().disableAutoAccept(sessionID, directory)
|
||||||
},
|
},
|
||||||
permissionsEnabled,
|
permissionsEnabled,
|
||||||
isPermissionAllowAll(directory: string) {
|
isPermissionAllowAll(directory: string) {
|
||||||
const [childStore] = serverSync().child(directory)
|
return selected().isPermissionAllowAll(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")
|
await ctx.store.sync("root")
|
||||||
|
|
||||||
expect(ctx.get).toEqual([{ sessionID: "root" }])
|
expect(ctx.get).toEqual([{ sessionID: "root" }])
|
||||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 2, before: undefined }])
|
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }])
|
||||||
expect(ctx.store.data.message.root).toEqual([])
|
expect(ctx.store.data.message.root).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ describe("server session", () => {
|
|||||||
|
|
||||||
await store.sync("child")
|
await store.sync("child")
|
||||||
|
|
||||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
|
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }])
|
||||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||||
expect(store.history.more("child")).toBe(true)
|
expect(store.history.more("child")).toBe(true)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } fro
|
|||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const initialMessagePageSize = 2
|
const initialMessagePageSize = 20
|
||||||
const historyMessagePageSize = 200
|
const historyMessagePageSize = 200
|
||||||
const sessionInfoLimit = 2_048
|
const sessionInfoLimit = 2_048
|
||||||
const emptyIDs: ReadonlySet<string> = new Set()
|
const emptyIDs: ReadonlySet<string> = new Set()
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { useGlobal } from "./global"
|
|||||||
import { ServerConnection, useServer } from "./server"
|
import { ServerConnection, useServer } from "./server"
|
||||||
import { retry } from "@opencode-ai/core/util/retry"
|
import { retry } from "@opencode-ai/core/util/retry"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
import { toggleMcp } from "./global-sync/mcp"
|
import { toggleMcp } from "./global-sync/mcp"
|
||||||
import { createServerSession } from "./server-session"
|
import { createServerSession } from "./server-session"
|
||||||
@@ -153,6 +154,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
|
||||||
|
|
||||||
let bootedAt = 0
|
let bootedAt = 0
|
||||||
let bootingRoot = false
|
let bootingRoot = false
|
||||||
@@ -376,6 +378,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||||
|
|
||||||
session.apply(event)
|
session.apply(event)
|
||||||
|
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
||||||
|
homeSessions.apply(event)
|
||||||
|
}
|
||||||
|
homeSessions.refresh(event.type)
|
||||||
|
|
||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
applyGlobalEvent({
|
applyGlobalEvent({
|
||||||
@@ -390,6 +396,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||||
if (recent) return
|
if (recent) return
|
||||||
for (const directory of Object.keys(children.children)) {
|
for (const directory of Object.keys(children.children)) {
|
||||||
|
if (!children.active(directory)) continue
|
||||||
queue.push(directory)
|
queue.push(directory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,15 +412,19 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
directory,
|
directory,
|
||||||
store,
|
store,
|
||||||
setStore,
|
setStore,
|
||||||
push: queue.push,
|
push: (directory) => {
|
||||||
|
if (children.active(directory)) queue.push(directory)
|
||||||
|
},
|
||||||
retainedLimit: sessionMeta.get(key)?.limit,
|
retainedLimit: sessionMeta.get(key)?.limit,
|
||||||
sessionContent: false,
|
sessionContent: false,
|
||||||
permission: session.data.permission,
|
permission: session.data.permission,
|
||||||
vcsCache: children.vcsCache.get(key),
|
vcsCache: children.vcsCache.get(key),
|
||||||
loadLsp: () => {
|
loadLsp: () => {
|
||||||
|
if (!children.active(key)) return
|
||||||
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
||||||
},
|
},
|
||||||
loadReferences: () => {
|
loadReferences: () => {
|
||||||
|
if (!children.active(key)) return
|
||||||
void queryClient.fetchQuery(queryOptionsApi.references(key))
|
void queryClient.fetchQuery(queryOptionsApi.references(key))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -486,6 +497,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
updateConfig: updateConfigMutation.mutateAsync,
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
session,
|
session,
|
||||||
|
homeSessions,
|
||||||
mcp: {
|
mcp: {
|
||||||
toggle: async (directory: string, name: string) => {
|
toggle: async (directory: string, name: string) => {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import {
|
import {
|
||||||
|
isAppUpgrade,
|
||||||
layoutTransitionState,
|
layoutTransitionState,
|
||||||
maximumSunsetTimeout,
|
maximumSunsetTimeout,
|
||||||
newLayoutDesignsDefault,
|
newLayoutDesignsDefault,
|
||||||
nextSunsetCheckDelay,
|
nextSunsetCheckDelay,
|
||||||
resolveNewLayoutDesigns,
|
resolveNewLayoutDesigns,
|
||||||
|
shouldDisplayTabsToast,
|
||||||
|
shouldEnableNewLayout,
|
||||||
} from "./settings"
|
} from "./settings"
|
||||||
|
|
||||||
describe("layout transition", () => {
|
describe("layout transition", () => {
|
||||||
@@ -37,4 +40,33 @@ describe("layout transition", () => {
|
|||||||
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
|
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
|
||||||
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
|
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("enables the new layout when upgrading from 1.17.19 or earlier", () => {
|
||||||
|
expect(shouldEnableNewLayout("v1.17.19", "1.17.20")).toBe(true)
|
||||||
|
expect(shouldEnableNewLayout("1.16.9", "2.0.0")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("enables the new layout when no previous version was recorded", () => {
|
||||||
|
expect(shouldEnableNewLayout(undefined, "1.17.20")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects upgrades only when a previous version is older", () => {
|
||||||
|
expect(isAppUpgrade("1.17.19", "1.17.20")).toBe(true)
|
||||||
|
expect(isAppUpgrade(undefined, "1.17.20")).toBe(false)
|
||||||
|
expect(isAppUpgrade("1.17.20", "1.17.20")).toBe(false)
|
||||||
|
expect(isAppUpgrade("1.17.21", "1.17.20")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shows the tabs toast for upgrades and existing installs without a recorded version", () => {
|
||||||
|
expect(shouldDisplayTabsToast("1.17.19", "1.17.20", false)).toBe(true)
|
||||||
|
expect(shouldDisplayTabsToast(undefined, "1.17.20", true)).toBe(true)
|
||||||
|
expect(shouldDisplayTabsToast(undefined, "1.17.20", false)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not enable the new layout without a qualifying upgrade", () => {
|
||||||
|
expect(shouldEnableNewLayout("1.17.19", "1.17.19")).toBe(false)
|
||||||
|
expect(shouldEnableNewLayout("1.17.20", "1.17.21")).toBe(false)
|
||||||
|
expect(shouldEnableNewLayout(undefined, "1.17.19")).toBe(false)
|
||||||
|
expect(shouldEnableNewLayout("dev", "1.17.20")).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createStore, reconcile } from "solid-js/store"
|
|||||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
agent: boolean
|
agent: boolean
|
||||||
@@ -36,6 +37,7 @@ export interface Settings {
|
|||||||
newLayoutDesigns?: boolean
|
newLayoutDesigns?: boolean
|
||||||
layoutTransitionEligible?: boolean
|
layoutTransitionEligible?: boolean
|
||||||
newInterfaceNoticeDismissed?: boolean
|
newInterfaceNoticeDismissed?: boolean
|
||||||
|
shouldDisplayTabsToast?: boolean
|
||||||
}
|
}
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: number
|
fontSize: number
|
||||||
@@ -57,7 +59,49 @@ export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
|||||||
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||||
export const newLayoutDesignsDefault = true
|
export const newLayoutDesignsDefault = true
|
||||||
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
|
// Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
|
||||||
export const oldInterfaceSunset = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" ? new Date(2026, 7, 14) : null
|
export const oldInterfaceSunset = new Date(2026, 8, 14)
|
||||||
|
const newLayoutDesignsUpgradeCutoff = "1.17.19"
|
||||||
|
|
||||||
|
function compareVersions(a: string, b: string) {
|
||||||
|
const parse = (version: string) => {
|
||||||
|
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i.exec(version.trim())
|
||||||
|
if (!match) return
|
||||||
|
return match.slice(1).map(Number)
|
||||||
|
}
|
||||||
|
const left = parse(a)
|
||||||
|
const right = parse(b)
|
||||||
|
if (!left || !right) return
|
||||||
|
const index = left.findIndex((part, index) => part !== right[index])
|
||||||
|
return index === -1 ? 0 : left[index]! - right[index]!
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAppUpgrade(previous: string | undefined, current: string | undefined) {
|
||||||
|
if (!previous || !current) return false
|
||||||
|
const comparison = compareVersions(current, previous)
|
||||||
|
return comparison !== undefined && comparison > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldDisplayTabsToast(
|
||||||
|
previous: string | undefined,
|
||||||
|
current: string | undefined,
|
||||||
|
existingInstall: boolean,
|
||||||
|
) {
|
||||||
|
return isAppUpgrade(previous, current) || (!previous && existingInstall)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
|
||||||
|
if (!current) return false
|
||||||
|
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
|
||||||
|
if (!previous) return currentComparison !== undefined && currentComparison > 0
|
||||||
|
if (!isAppUpgrade(previous, current)) return false
|
||||||
|
const previousComparison = compareVersions(previous, newLayoutDesignsUpgradeCutoff)
|
||||||
|
return (
|
||||||
|
previousComparison !== undefined &&
|
||||||
|
currentComparison !== undefined &&
|
||||||
|
previousComparison <= 0 &&
|
||||||
|
currentComparison > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
|
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
|
||||||
return {
|
return {
|
||||||
@@ -175,7 +219,17 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
name: "Settings",
|
name: "Settings",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
|
const platform = usePlatform()
|
||||||
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||||
|
const [launch, setLaunch, , launchReady] = persisted(
|
||||||
|
"app-version.v1",
|
||||||
|
createStore<{ version?: string }>({ version: undefined }),
|
||||||
|
)
|
||||||
|
const [launchState, setLaunchState] = createStore({
|
||||||
|
classified: false,
|
||||||
|
migrationApplied: false,
|
||||||
|
previous: undefined as string | undefined,
|
||||||
|
})
|
||||||
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
||||||
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
||||||
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
||||||
@@ -185,18 +239,26 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
)
|
)
|
||||||
const sunset = oldInterfaceSunset
|
const sunset = oldInterfaceSunset
|
||||||
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
|
const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
|
||||||
const layoutTransitionClassified = createMemo(
|
const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
|
||||||
() => typeof store.general?.layoutTransitionEligible === "boolean",
|
|
||||||
)
|
|
||||||
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
|
const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
|
||||||
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
|
const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
|
||||||
|
const layoutUpgrade = createMemo(() =>
|
||||||
|
launchState.classified && !launchState.migrationApplied
|
||||||
|
? shouldEnableNewLayout(launchState.previous, platform.version)
|
||||||
|
: false,
|
||||||
|
)
|
||||||
const layoutTransition = createMemo(() =>
|
const layoutTransition = createMemo(() =>
|
||||||
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
||||||
)
|
)
|
||||||
const newLayoutDesigns = createMemo(() => {
|
const newLayoutDesigns = createMemo(() => {
|
||||||
|
if (layoutUpgrade()) return true
|
||||||
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
||||||
if (!layoutTransitionClassified()) {
|
if (!layoutTransitionClassified()) {
|
||||||
return resolveNewLayoutDesigns(oldInterfaceRetired(), store.general?.newLayoutDesigns, legacyNewLayoutDesignsDefault)
|
return resolveNewLayoutDesigns(
|
||||||
|
oldInterfaceRetired(),
|
||||||
|
store.general?.newLayoutDesigns,
|
||||||
|
legacyNewLayoutDesignsDefault,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return resolveNewLayoutDesigns(
|
return resolveNewLayoutDesigns(
|
||||||
oldInterfaceRetired(),
|
oldInterfaceRetired(),
|
||||||
@@ -221,6 +283,35 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!launchReady() || launchState.classified) return
|
||||||
|
setLaunchState({
|
||||||
|
classified: true,
|
||||||
|
previous: launch.version,
|
||||||
|
})
|
||||||
|
if (!platform.version || launch.version === platform.version) return
|
||||||
|
setLaunch("version", platform.version)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !launchState.classified || launchState.migrationApplied) return
|
||||||
|
if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) {
|
||||||
|
setStore("general", "newLayoutDesigns", true)
|
||||||
|
}
|
||||||
|
setLaunchState("migrationApplied", true)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !launchState.classified) return
|
||||||
|
if (typeof store.general?.shouldDisplayTabsToast === "boolean") return
|
||||||
|
if (!launchState.previous && !layoutTransitionClassified()) return
|
||||||
|
setStore(
|
||||||
|
"general",
|
||||||
|
"shouldDisplayTabsToast",
|
||||||
|
shouldDisplayTabsToast(launchState.previous, platform.version, layoutTransitionEligible()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready() || !oldInterfaceRetired()) return
|
if (!ready() || !oldInterfaceRetired()) return
|
||||||
if (store.general?.newLayoutDesigns === true) return
|
if (store.general?.newLayoutDesigns === true) return
|
||||||
@@ -314,7 +405,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
},
|
},
|
||||||
newLayoutDesigns,
|
newLayoutDesigns,
|
||||||
setNewLayoutDesigns(value: boolean) {
|
setNewLayoutDesigns(value: boolean) {
|
||||||
setStore("general", "newLayoutDesigns", oldInterfaceRetired() ? true : value)
|
const next = oldInterfaceRetired() ? true : value
|
||||||
|
if (newLayoutDesigns() === next) return
|
||||||
|
setStore("general", "newLayoutDesigns", next)
|
||||||
|
if (typeof window !== "undefined") setTimeout(() => window.location.reload())
|
||||||
},
|
},
|
||||||
layoutTransitionClassified,
|
layoutTransitionClassified,
|
||||||
setOldLayoutEligible(eligible: boolean) {
|
setOldLayoutEligible(eligible: boolean) {
|
||||||
@@ -327,6 +421,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
dismissNewInterfaceNotice() {
|
dismissNewInterfaceNotice() {
|
||||||
setStore("general", "newInterfaceNoticeDismissed", true)
|
setStore("general", "newInterfaceNoticeDismissed", true)
|
||||||
},
|
},
|
||||||
|
shouldDisplayTabsToast: withFallback(() => store.general?.shouldDisplayTabsToast, false),
|
||||||
|
dismissTabsToast() {
|
||||||
|
setStore("general", "shouldDisplayTabsToast", false)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
visibility: {
|
visibility: {
|
||||||
fileTree: visible(showFileTree),
|
fileTree: visible(showFileTree),
|
||||||
|
|||||||
@@ -208,11 +208,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
||||||
return tab
|
return tab
|
||||||
},
|
},
|
||||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
|
async newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
|
||||||
const draftID = uuid()
|
const draftID = uuid()
|
||||||
const tab = { type: "draft" as const, draftID, ...draft }
|
const tab = { type: "draft" as const, draftID, ...draft }
|
||||||
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
|
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
|
||||||
void startTransition(() => {
|
await startTransition(() => {
|
||||||
setStore(
|
setStore(
|
||||||
produce((tabs) => {
|
produce((tabs) => {
|
||||||
tabs.push(tab)
|
tabs.push(tab)
|
||||||
@@ -220,6 +220,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
|||||||
)
|
)
|
||||||
navigate(draftHref(draftID))
|
navigate(draftHref(draftID))
|
||||||
})
|
})
|
||||||
|
return tab
|
||||||
},
|
},
|
||||||
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||||
void startTransition(() => {
|
void startTransition(() => {
|
||||||
|
|||||||
@@ -163,6 +163,43 @@ function createWorkspaceTerminalSession(
|
|||||||
all: [],
|
all: [],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
const [ui, setUi] = createStore({
|
||||||
|
focus: undefined as { request: number; id?: string; pending: boolean } | undefined,
|
||||||
|
})
|
||||||
|
const focus = { request: 0 }
|
||||||
|
|
||||||
|
const requestFocus = (id?: string, pending = false) => {
|
||||||
|
focus.request += 1
|
||||||
|
setUi("focus", { request: focus.request, id, pending })
|
||||||
|
return focus.request
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusRequested = (id?: string) => {
|
||||||
|
if (!id) return false
|
||||||
|
if (!ui.focus || ui.focus.pending) return false
|
||||||
|
return !ui.focus.id || ui.focus.id === id
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumeFocus = (id: string) => {
|
||||||
|
if (!focusRequested(id)) return
|
||||||
|
setUi("focus", undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelFocus = (request?: number) => {
|
||||||
|
if (request !== undefined && ui.focus?.request !== request) return
|
||||||
|
setUi("focus", undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
const cancelOnOutsideFocus = (event: FocusEvent) => {
|
||||||
|
if (!ui.focus) return
|
||||||
|
if (!(event.target instanceof Element)) return
|
||||||
|
if (event.target.closest("#terminal-panel")) return
|
||||||
|
cancelFocus()
|
||||||
|
}
|
||||||
|
document.addEventListener("focusin", cancelOnOutsideFocus)
|
||||||
|
onCleanup(() => document.removeEventListener("focusin", cancelOnOutsideFocus))
|
||||||
|
}
|
||||||
|
|
||||||
const pickNextTerminalNumber = () => {
|
const pickNextTerminalNumber = () => {
|
||||||
const existingTitleNumbers = new Set(
|
const existingTitleNumbers = new Set(
|
||||||
@@ -267,23 +304,33 @@ function createWorkspaceTerminalSession(
|
|||||||
setStore("all", [])
|
setStore("all", [])
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
new() {
|
new(options?: { focus?: boolean }) {
|
||||||
const nextNumber = pickNextTerminalNumber()
|
const nextNumber = pickNextTerminalNumber()
|
||||||
|
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
|
||||||
|
|
||||||
sdk.client.pty
|
sdk.client.pty
|
||||||
.create({ title: defaultTitle(nextNumber) })
|
.create({ title: defaultTitle(nextNumber) })
|
||||||
.then((pty: { data?: { id?: string; title?: string } }) => {
|
.then((pty: { data?: { id?: string; title?: string } }) => {
|
||||||
const id = pty.data?.id
|
const id = pty.data?.id
|
||||||
if (!id) return
|
if (!id) {
|
||||||
|
if (focusRequest !== undefined) cancelFocus(focusRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
const newTerminal = {
|
const newTerminal = {
|
||||||
id,
|
id,
|
||||||
title: pty.data?.title ?? defaultTitle(nextNumber),
|
title: pty.data?.title ?? defaultTitle(nextNumber),
|
||||||
titleNumber: nextNumber,
|
titleNumber: nextNumber,
|
||||||
}
|
}
|
||||||
setStore("all", store.all.length, newTerminal)
|
batch(() => {
|
||||||
setStore("active", id)
|
setStore("all", store.all.length, newTerminal)
|
||||||
|
setStore("active", id)
|
||||||
|
if (focusRequest !== undefined && ui.focus?.request === focusRequest) {
|
||||||
|
setUi("focus", { request: focusRequest, id, pending: false })
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
|
if (focusRequest !== undefined) cancelFocus(focusRequest)
|
||||||
console.error("Failed to create terminal", error)
|
console.error("Failed to create terminal", error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -324,6 +371,18 @@ function createWorkspaceTerminalSession(
|
|||||||
open(id: string) {
|
open(id: string) {
|
||||||
setStore("active", id)
|
setStore("active", id)
|
||||||
},
|
},
|
||||||
|
requestFocus(id?: string) {
|
||||||
|
requestFocus(id)
|
||||||
|
},
|
||||||
|
focusRequested(id?: string) {
|
||||||
|
return focusRequested(id)
|
||||||
|
},
|
||||||
|
consumeFocus(id: string) {
|
||||||
|
consumeFocus(id)
|
||||||
|
},
|
||||||
|
cancelFocus() {
|
||||||
|
cancelFocus()
|
||||||
|
},
|
||||||
next() {
|
next() {
|
||||||
const index = store.all.findIndex((x) => x.id === store.active)
|
const index = store.all.findIndex((x) => x.id === store.active)
|
||||||
if (index === -1) return
|
if (index === -1) return
|
||||||
@@ -442,13 +501,17 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
ready: () => workspace().ready(),
|
ready: () => workspace().ready(),
|
||||||
all: () => workspace().all(),
|
all: () => workspace().all(),
|
||||||
active: () => workspace().active(),
|
active: () => workspace().active(),
|
||||||
new: () => workspace().new(),
|
new: (options?: { focus?: boolean }) => workspace().new(options),
|
||||||
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
||||||
trim: (id: string) => workspace().trim(id),
|
trim: (id: string) => workspace().trim(id),
|
||||||
trimAll: () => workspace().trimAll(),
|
trimAll: () => workspace().trimAll(),
|
||||||
clone: (id: string) => workspace().clone(id),
|
clone: (id: string) => workspace().clone(id),
|
||||||
bind: () => workspace(),
|
bind: () => workspace(),
|
||||||
open: (id: string) => workspace().open(id),
|
open: (id: string) => workspace().open(id),
|
||||||
|
requestFocus: (id?: string) => workspace().requestFocus(id),
|
||||||
|
focusRequested: (id?: string) => workspace().focusRequested(id),
|
||||||
|
consumeFocus: (id: string) => workspace().consumeFocus(id),
|
||||||
|
cancelFocus: () => workspace().cancelFocus(),
|
||||||
close: (id: string) => workspace().close(id),
|
close: (id: string) => workspace().close(id),
|
||||||
move: (id: string, to: number) => workspace().move(id, to),
|
move: (id: string, to: number) => workspace().move(id, to),
|
||||||
next: () => workspace().next(),
|
next: () => workspace().next(),
|
||||||
|
|||||||
@@ -35,12 +35,12 @@ import { usePlatform } from "@/context/platform"
|
|||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||||
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
||||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||||
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
|
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
|
||||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import {
|
import {
|
||||||
@@ -50,7 +50,6 @@ import {
|
|||||||
getProjectAvatarSource,
|
getProjectAvatarSource,
|
||||||
homeProjectDirectories,
|
homeProjectDirectories,
|
||||||
projectForSession,
|
projectForSession,
|
||||||
sortedRootSessions,
|
|
||||||
toggleHomeProjectSelection,
|
toggleHomeProjectSelection,
|
||||||
} from "@/pages/layout/helpers"
|
} from "@/pages/layout/helpers"
|
||||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||||
@@ -69,6 +68,11 @@ import { archiveHomeSession } from "./home-session-archive"
|
|||||||
import { shouldOpenSessionInBackground } from "./home-session-open"
|
import { shouldOpenSessionInBackground } from "./home-session-open"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { fileManagerApp } from "@/utils/file-manager"
|
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_LIMIT = 64
|
||||||
const HOME_SESSION_HEADER_STICKY_TOP = 12
|
const HOME_SESSION_HEADER_STICKY_TOP = 12
|
||||||
@@ -106,22 +110,24 @@ const HOME_SEARCH_RESULT_META =
|
|||||||
let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
|
let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
|
||||||
|
|
||||||
function buildHomeSessionRecords(input: {
|
function buildHomeSessionRecords(input: {
|
||||||
sync: Pick<ServerSync, "child">
|
sessions: () => Session[]
|
||||||
projectDirectories: () => string[]
|
projectDirectories: () => string[]
|
||||||
projects: () => LocalProject[]
|
projects: () => LocalProject[]
|
||||||
projectByID: () => Map<string, LocalProject>
|
projectByID: () => Map<string, LocalProject>
|
||||||
}) {
|
}) {
|
||||||
return [
|
const directories = new Set(input.projectDirectories().map(pathKey))
|
||||||
...new Map(
|
const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory)))
|
||||||
input
|
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
||||||
.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))
|
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||||
.flatMap((session) => {
|
.flatMap((session) => {
|
||||||
const project = projectForSession(session, input.projects(), input.projectByID())
|
const directory = pathKey(session.directory)
|
||||||
|
const project =
|
||||||
|
input
|
||||||
|
.projects()
|
||||||
|
.find(
|
||||||
|
(item) =>
|
||||||
|
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||||
|
) ?? projectForSession(session, input.projects(), input.projectByID())
|
||||||
if (!project) return []
|
if (!project) return []
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
@@ -269,7 +275,7 @@ export function NewHome() {
|
|||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const marked = useMarked()
|
const marked = useMarked()
|
||||||
const openSettings = useSettingsDialog()
|
const openSettings = useSettingsCommand()
|
||||||
let focusSessionSearch: (() => void) | undefined
|
let focusSessionSearch: (() => void) | undefined
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
search: "",
|
search: "",
|
||||||
@@ -286,6 +292,7 @@ export function NewHome() {
|
|||||||
return global.ensureServerCtx(conn)
|
return global.ensureServerCtx(conn)
|
||||||
})
|
})
|
||||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
||||||
|
const homeSessions = () => focusedSync().homeSessions
|
||||||
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
|
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
|
||||||
const recentlyClosed = createMemo(
|
const recentlyClosed = createMemo(
|
||||||
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
|
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
|
||||||
@@ -318,24 +325,47 @@ export function NewHome() {
|
|||||||
}
|
}
|
||||||
return language.t("home.sessions.search.placeholder")
|
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(() => ({
|
const sessionLoad = useQuery(() => ({
|
||||||
queryKey: ["home", "sessions", selection().server, ...projectDirectories()] as const,
|
queryKey: homeSessions().indexKey,
|
||||||
queryFn: async () => {
|
enabled: !!focusedServerCtx(),
|
||||||
await Promise.all(
|
queryFn: async ({ signal }) => {
|
||||||
projectDirectories().map((directory) =>
|
const ctx = focusedServerCtx()
|
||||||
focusedSync().project.loadSessions(directory, { limit: HOME_SESSION_LIMIT }),
|
if (!ctx) return { sessions: [], eventSequence: 0 }
|
||||||
),
|
const cache = homeSessions()
|
||||||
|
const eventSequence = cache.eventSequence()
|
||||||
|
const index = await loadHomeSessionIndex(
|
||||||
|
(input, options) => ctx.sdk.client.v2.session.list(input, options),
|
||||||
|
eventSequence,
|
||||||
|
signal,
|
||||||
)
|
)
|
||||||
return null
|
cache.complete(eventSequence)
|
||||||
|
return index
|
||||||
},
|
},
|
||||||
|
retry: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const projectByID = createMemo(
|
const projectByID = createMemo(
|
||||||
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
() => 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(() =>
|
const allRecords = createMemo(() =>
|
||||||
buildHomeSessionRecords({
|
buildHomeSessionRecords({
|
||||||
sync: focusedSync(),
|
sessions: indexedSessions,
|
||||||
projectDirectories,
|
projectDirectories,
|
||||||
projects,
|
projects,
|
||||||
projectByID,
|
projectByID,
|
||||||
@@ -363,8 +393,7 @@ export function NewHome() {
|
|||||||
prefetched.add(key)
|
prefetched.add(key)
|
||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
try {
|
try {
|
||||||
const directory = ctx.sync.ensureDirSyncContext(record.session.directory)
|
void ctx.sync.session
|
||||||
void directory.session
|
|
||||||
.sync(record.session.id)
|
.sync(record.session.id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
@@ -484,7 +513,13 @@ export function NewHome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSession(session: Session, options?: OpenSessionOptions) {
|
function openSession(session: Session, options?: OpenSessionOptions) {
|
||||||
const project = projectForSession(session, projects(), projectByID())
|
const directoryKey = pathKey(session.directory)
|
||||||
|
const project =
|
||||||
|
projects().find(
|
||||||
|
(item) =>
|
||||||
|
pathKey(item.worktree) === directoryKey ||
|
||||||
|
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
|
||||||
|
) ?? projectForSession(session, projects(), projectByID())
|
||||||
const conn = focusedServer()
|
const conn = focusedServer()
|
||||||
if (!conn) return
|
if (!conn) return
|
||||||
const directory = project?.worktree ?? session.directory
|
const directory = project?.worktree ?? session.directory
|
||||||
@@ -795,7 +830,7 @@ function HomeUtilityNav(props: {
|
|||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1`}>
|
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { setNavigate } from "@/utils/notification-click"
|
import { setNavigate } from "@/utils/notification-click"
|
||||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
|
||||||
|
|
||||||
export default function NewLayout(props: ParentProps) {
|
export default function NewLayout(props: ParentProps) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
setNavigate(navigate)
|
setNavigate(navigate)
|
||||||
useSettingsCommand()
|
|
||||||
|
|
||||||
createEffect(() => setV2Toast(true))
|
createEffect(() => setV2Toast(true))
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function useSessionTabAvatarState(
|
|||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
|
const permissionState = createMemo(() => permission.ensureServerState(server()))
|
||||||
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
||||||
const sync = createMemo(() => {
|
const sync = createMemo(() => {
|
||||||
const conn = connection()
|
const conn = connection()
|
||||||
@@ -23,7 +24,7 @@ export function useSessionTabAvatarState(
|
|||||||
if (!serverSync) return false
|
if (!serverSync) return false
|
||||||
const [store] = serverSync.child(directory(), { bootstrap: false })
|
const [store] = serverSync.child(directory(), { bootstrap: false })
|
||||||
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
|
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
|
||||||
return !permission.autoResponds(item, directory())
|
return !permissionState().autoResponds(item, directory())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const hasQuestions = createMemo(() => {
|
const hasQuestions = createMemo(() => {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
|||||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
import { useSettingsCommand, useSettingsDialog } from "@/components/settings-dialog"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import createPresence from "solid-presence"
|
import createPresence from "solid-presence"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
@@ -54,6 +54,7 @@ export default function NewSessionPage() {
|
|||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const openProviderSettings = useSettingsDialog("providers")
|
const openProviderSettings = useSettingsDialog("providers")
|
||||||
|
useSettingsCommand()
|
||||||
const route = useSessionKey()
|
const route = useSessionKey()
|
||||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { PermissionProvider } from "@/context/permission"
|
|
||||||
import { PromptProvider, usePrompt } from "@/context/prompt"
|
import { PromptProvider, usePrompt } from "@/context/prompt"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
@@ -58,6 +57,9 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { TerminalProvider, useTerminal } from "@/context/terminal"
|
import { TerminalProvider, useTerminal } from "@/context/terminal"
|
||||||
import { PromptInput } from "@/components/prompt-input"
|
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 { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||||
import {
|
import {
|
||||||
createPromptInputController,
|
createPromptInputController,
|
||||||
@@ -153,13 +155,25 @@ export function SessionPage() {
|
|||||||
// workspace-scoped state (terminal, directory providers) lives below.
|
// workspace-scoped state (terminal, directory providers) lives below.
|
||||||
export function TargetSessionRouteContent() {
|
export function TargetSessionRouteContent() {
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
|
const serverSync = useServerSync()
|
||||||
|
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
|
||||||
return (
|
return (
|
||||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
// Settings must keep the target-server SDK, sync, and models context and remain registered
|
||||||
<ResolvedTargetSessionRoute />
|
// when session content falls back to the route error boundary.
|
||||||
</SessionRouteErrorBoundary>
|
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||||
|
<TargetSessionSettingsCommand />
|
||||||
|
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
||||||
|
<ResolvedTargetSessionRoute />
|
||||||
|
</SessionRouteErrorBoundary>
|
||||||
|
</TargetServerScopedProviders>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TargetSessionSettingsCommand() {
|
||||||
|
useSettingsCommand()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function SessionRouteErrorBoundary(
|
export function SessionRouteErrorBoundary(
|
||||||
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
|
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
|
||||||
) {
|
) {
|
||||||
@@ -248,19 +262,17 @@ function ResolvedTargetSessionRoute() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
// Non-keyed: closes only while the target's directory is unknown (uncached
|
||||||
{/* Non-keyed: closes only while the target's directory is unknown (uncached
|
// lineage mid-resolution), which tears down the workspace subtree including
|
||||||
lineage mid-resolution), which tears down the workspace subtree including
|
// the terminal. Same-workspace tab switches keep it open because warm
|
||||||
the terminal. Same-workspace tab switches keep it open because warm
|
// targets resolve synchronously from the sync cache.
|
||||||
targets resolve synchronously from the sync cache. */}
|
<Show when={directory()}>
|
||||||
<Show when={directory()}>
|
<SDKProvider directory={targetDirectory}>
|
||||||
<SDKProvider directory={targetDirectory}>
|
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
<TargetSessionPage />
|
||||||
<TargetSessionPage />
|
</DirectoryDataProvider>
|
||||||
</DirectoryDataProvider>
|
</SDKProvider>
|
||||||
</SDKProvider>
|
</Show>
|
||||||
</Show>
|
|
||||||
</TargetServerScopedProviders>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,10 +293,10 @@ function TargetServerScopedProviders(
|
|||||||
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
|
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<>
|
||||||
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
</PermissionProvider>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1054,7 +1066,10 @@ export default function Page() {
|
|||||||
|
|
||||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||||
if (composer.blocked() || isChildSession()) return
|
if (composer.blocked() || isChildSession()) return
|
||||||
inputRef?.focus()
|
const input = inputRef
|
||||||
|
if (!input) return
|
||||||
|
input.focus()
|
||||||
|
setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -757,7 +757,7 @@ function SessionFileViewV2(props: { tab: string }) {
|
|||||||
commentsUi.onLineNumberSelectionEnd(range)
|
commentsUi.onLineNumberSelectionEnd(range)
|
||||||
}}
|
}}
|
||||||
search={search}
|
search={search}
|
||||||
class="select-text"
|
class="select-text [--opencode-diffs-bg:var(--v2-background-bg-base)]"
|
||||||
media={{
|
media={{
|
||||||
mode: "auto",
|
mode: "auto",
|
||||||
path: path(),
|
path: path(),
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
|||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
|
||||||
export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||||
const delays = [120, 240]
|
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -46,6 +45,8 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
|||||||
let root: HTMLDivElement | undefined
|
let root: HTMLDivElement | undefined
|
||||||
let tabList: HTMLDivElement | undefined
|
let tabList: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
onCleanup(() => terminal.cancelFocus())
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
autoCreated: false,
|
autoCreated: false,
|
||||||
recovered: {} as Record<string, boolean>,
|
recovered: {} as Record<string, boolean>,
|
||||||
@@ -94,36 +95,12 @@ 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(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => [opened(), terminal.active()] as const,
|
() => [opened(), terminal.active(), terminal.focusRequested(terminal.active())] as const,
|
||||||
([next, id]) => {
|
([next, id, requested]) => {
|
||||||
if (!next || !id) return
|
if (!next || !id || !requested) return
|
||||||
const stop = focus(id)
|
focusTerminalById(id)
|
||||||
onCleanup(stop)
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -285,7 +262,15 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
|||||||
onChange={(id) => terminal.open(id)}
|
onChange={(id) => terminal.open(id)}
|
||||||
class={newLayout() ? "!h-[52px] !flex-none" : "!h-auto !flex-none"}
|
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"}>
|
<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()
|
||||||
|
}}
|
||||||
|
>
|
||||||
<For each={all()}>
|
<For each={all()}>
|
||||||
{(pty, index) => (
|
{(pty, index) => (
|
||||||
<SortableTerminalTabV2 terminal={pty} index={index} newLayout={newLayout()} onClose={close} />
|
<SortableTerminalTabV2 terminal={pty} index={index} newLayout={newLayout()} onClose={close} />
|
||||||
@@ -304,7 +289,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
|||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
iconSize="large"
|
iconSize="large"
|
||||||
onClick={terminal.new}
|
onClick={() => terminal.new({ focus: true })}
|
||||||
aria-label={language.t("command.terminal.new")}
|
aria-label={language.t("command.terminal.new")}
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
@@ -326,7 +311,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
|||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
iconSize="large"
|
iconSize="large"
|
||||||
onClick={terminal.new}
|
onClick={() => terminal.new({ focus: true })}
|
||||||
aria-label={language.t("command.terminal.new")}
|
aria-label={language.t("command.terminal.new")}
|
||||||
/>
|
/>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
@@ -344,7 +329,8 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
|||||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||||
<Terminal
|
<Terminal
|
||||||
pty={pty()}
|
pty={pty()}
|
||||||
autoFocus={opened()}
|
autoFocus={terminal.focusRequested(id)}
|
||||||
|
onAutoFocus={() => terminal.consumeFocus(id)}
|
||||||
class="!px-[14px]"
|
class="!px-[14px]"
|
||||||
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
|
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
|
||||||
onCleanup={ops.update}
|
onCleanup={ops.update}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export function TerminalPanel() {
|
|||||||
const close = () => view().terminal.close()
|
const close = () => view().terminal.close()
|
||||||
let root: HTMLDivElement | undefined
|
let root: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
onCleanup(() => terminal.cancelFocus())
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
autoCreated: false,
|
autoCreated: false,
|
||||||
activeDraggable: undefined as string | undefined,
|
activeDraggable: undefined as string | undefined,
|
||||||
@@ -285,7 +287,7 @@ export function TerminalPanel() {
|
|||||||
icon="plus-small"
|
icon="plus-small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
iconSize="large"
|
iconSize="large"
|
||||||
onClick={terminal.new}
|
onClick={() => terminal.new()}
|
||||||
aria-label={language.t("command.terminal.new")}
|
aria-label={language.t("command.terminal.new")}
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
@@ -303,6 +305,7 @@ export function TerminalPanel() {
|
|||||||
<Terminal
|
<Terminal
|
||||||
pty={pty()}
|
pty={pty()}
|
||||||
autoFocus={opened()}
|
autoFocus={opened()}
|
||||||
|
onAutoFocus={() => terminal.consumeFocus(id)}
|
||||||
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
|
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
|
||||||
onCleanup={ops.update}
|
onCleanup={ops.update}
|
||||||
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
|
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import { useSync } from "@/context/sync"
|
|||||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||||
import { sessionTitle } from "@/utils/session-title"
|
import { sessionTitle } from "@/utils/session-title"
|
||||||
import { scheduleConnectedMeasure } from "./measure"
|
import { scheduleConnectedMeasure } from "./measure"
|
||||||
|
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||||
import { createTimelineProjection } from "./projection"
|
import { createTimelineProjection } from "./projection"
|
||||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||||
import { filterVirtualIndexes } from "./virtual-items"
|
import { filterVirtualIndexes } from "./virtual-items"
|
||||||
@@ -408,6 +409,7 @@ export function MessageTimeline(props: {
|
|||||||
return timelineRows().length
|
return timelineRows().length
|
||||||
},
|
},
|
||||||
getScrollElement: () => listRoot() ?? null,
|
getScrollElement: () => listRoot() ?? null,
|
||||||
|
observeElementOffset: observeElementOffsetReconnectAware,
|
||||||
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
|
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
|
||||||
initialMeasurementsCache: initialMeasurements,
|
initialMeasurementsCache: initialMeasurements,
|
||||||
estimateSize: () => timelineFallbackItemSize,
|
estimateSize: () => timelineFallbackItemSize,
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
@@ -264,7 +264,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openTerminal = () => {
|
const openTerminal = () => {
|
||||||
if (terminal.all().length > 0) terminal.new()
|
if (terminal.all().length > 0) terminal.new({ focus: true })
|
||||||
|
if (terminal.all().length === 0) terminal.requestFocus()
|
||||||
view().terminal.open()
|
view().terminal.open()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,7 +499,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
title: language.t("command.terminal.toggle"),
|
title: language.t("command.terminal.toggle"),
|
||||||
keybind: "ctrl+`",
|
keybind: "ctrl+`",
|
||||||
slash: "terminal",
|
slash: "terminal",
|
||||||
onSelect: () => view().terminal.toggle(),
|
onSelect: () => {
|
||||||
|
if (view().terminal.opened()) {
|
||||||
|
terminal.cancelFocus()
|
||||||
|
view().terminal.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
terminal.requestFocus(terminal.active())
|
||||||
|
view().terminal.open()
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
viewCommand({
|
viewCommand({
|
||||||
id: "review.toggle",
|
id: "review.toggle",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { createStore } from "solid-js/store"
|
|||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
|
||||||
export function createReviewPanelV2State() {
|
export function createReviewPanelV2State() {
|
||||||
const [store, setStore] = persisted(
|
const [store, setStore, , ready] = persisted(
|
||||||
Persist.global("review-panel-v2"),
|
Persist.global("review-panel-v2"),
|
||||||
createStore({
|
createStore({
|
||||||
sidebarOpened: true,
|
sidebarOpened: true,
|
||||||
@@ -24,6 +24,7 @@ export function createReviewPanelV2State() {
|
|||||||
return {
|
return {
|
||||||
sidebarOpened: () => store.sidebarOpened,
|
sidebarOpened: () => store.sidebarOpened,
|
||||||
sidebarWidth: () => store.sidebarWidth,
|
sidebarWidth: () => store.sidebarWidth,
|
||||||
|
sidebarTransition: ready,
|
||||||
filter,
|
filter,
|
||||||
setFilter,
|
setFilter,
|
||||||
expandMode: () => store.expandMode,
|
expandMode: () => store.expandMode,
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ function ReviewPanelV2Sidebar(props: {
|
|||||||
return (
|
return (
|
||||||
<SessionReviewV2Sidebar
|
<SessionReviewV2Sidebar
|
||||||
open={props.state.sidebarOpened()}
|
open={props.state.sidebarOpened()}
|
||||||
|
transition={props.state.sidebarTransition()}
|
||||||
title={props.title}
|
title={props.title}
|
||||||
stats={<DiffChanges changes={props.diffs()} />}
|
stats={<DiffChanges changes={props.diffs()} />}
|
||||||
filter={props.state.filter()}
|
filter={props.state.filter()}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const emptyFiles: string[] = []
|
|||||||
export type SessionFileBrowserState = {
|
export type SessionFileBrowserState = {
|
||||||
sidebarOpened: () => boolean
|
sidebarOpened: () => boolean
|
||||||
sidebarWidth: () => number
|
sidebarWidth: () => number
|
||||||
|
sidebarTransition: () => boolean
|
||||||
resizeSidebar: (width: number) => void
|
resizeSidebar: (width: number) => void
|
||||||
toggleSidebar: () => void
|
toggleSidebar: () => void
|
||||||
}
|
}
|
||||||
@@ -96,6 +97,7 @@ export function SessionFileBrowserTab(props: {
|
|||||||
sidebar={
|
sidebar={
|
||||||
<SessionReviewV2Sidebar
|
<SessionReviewV2Sidebar
|
||||||
open={sidebarOpened()}
|
open={sidebarOpened()}
|
||||||
|
transition={props.state.sidebarTransition()}
|
||||||
title={<span class="truncate">{title()}</span>}
|
title={<span class="truncate">{title()}</span>}
|
||||||
filter={filter()}
|
filter={filter()}
|
||||||
onFilterChange={setFilter}
|
onFilterChange={setFilter}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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,8 +1,32 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
import { createVirtualizer, defaultRangeExtractor, Virtualizer } from "@tanstack/solid-virtual"
|
||||||
import { createRoot, createSignal } from "solid-js"
|
import { createRoot, createSignal } from "solid-js"
|
||||||
import { filterVirtualIndexes } from "@/pages/session/timeline/virtual-items"
|
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", () => {
|
test("reactive count updates preserve measured row sizes", () => {
|
||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
const [count, setCount] = createSignal(2)
|
const [count, setCount] = createSignal(2)
|
||||||
@@ -42,23 +66,26 @@ test("initial rect projects rows before a scroll element connects", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("logical scroll offset includes pending measurement adjustments", () => {
|
test("clamps oversized offsets with scroll margin and padding changes", () => {
|
||||||
createRoot((dispose) => {
|
const options = (paddingEnd: number) => ({
|
||||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
count: 20,
|
||||||
count: 2,
|
estimateSize: () => 60,
|
||||||
getScrollElement: () => null,
|
initialOffset: Number.MAX_SAFE_INTEGER,
|
||||||
estimateSize: () => 60,
|
initialRect: { width: 800, height: 600 },
|
||||||
initialOffset: 100,
|
scrollMargin: 64,
|
||||||
initialRect: { width: 800, height: 60 },
|
paddingEnd,
|
||||||
})
|
overscan: 1,
|
||||||
|
getScrollElement: () => null,
|
||||||
virtualizer.getTotalSize()
|
scrollToFn: () => {},
|
||||||
virtualizer.resizeItem(0, 100)
|
observeElementRect: () => {},
|
||||||
|
observeElementOffset: () => {},
|
||||||
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", () => {
|
test("stale pinned indexes do not produce missing virtual items after count shrinks", () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/codemode",
|
"name": "@opencode-ai/codemode",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"description": "Effect-native confined code execution over schema-described tools",
|
"description": "Effect-native confined code execution over schema-described tools",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import "./app.css"
|
|||||||
import { LanguageProvider } from "~/context/language"
|
import { LanguageProvider } from "~/context/language"
|
||||||
import { I18nProvider, useI18n } from "~/context/i18n"
|
import { I18nProvider, useI18n } from "~/context/i18n"
|
||||||
import { strip } from "~/lib/language"
|
import { strip } from "~/lib/language"
|
||||||
|
import { DesktopPromo } from "~/component/desktop-promo"
|
||||||
|
|
||||||
function AppMeta() {
|
function AppMeta() {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
@@ -33,6 +34,7 @@ export default function App() {
|
|||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
<AppMeta />
|
<AppMeta />
|
||||||
<Suspense>{props.children}</Suspense>
|
<Suspense>{props.children}</Suspense>
|
||||||
|
<DesktopPromo />
|
||||||
</MetaProvider>
|
</MetaProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,101 @@
|
|||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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,10 +102,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "شعار opencode الداكن",
|
"temp.logoDarkAlt": "شعار opencode الداكن",
|
||||||
|
|
||||||
"home.banner.badge": "جديد",
|
"home.banner.badge": "جديد",
|
||||||
"home.banner.text": "تطبيق سطح المكتب متاح بنسخة تجريبية",
|
"home.banner.text": "نقدّم علامات التبويب لتطبيق سطح المكتب.",
|
||||||
"home.banner.platforms": "على macOS، Windows، وLinux",
|
"home.banner.platforms": "متاحة على macOS وWindows وLinux",
|
||||||
"home.banner.downloadNow": "حمّل الآن",
|
"home.banner.downloadNow": "حمّل الآن",
|
||||||
"home.banner.downloadBetaNow": "حمّل النسخة التجريبية لتطبيق سطح المكتب الآن",
|
"home.banner.downloadBetaNow": "حمّل النسخة التجريبية لتطبيق سطح المكتب الآن",
|
||||||
|
"home.promo.title": "نقدّم علامات التبويب لتطبيق سطح المكتب",
|
||||||
|
"home.promo.body": "نظّم عملك وجلساتك النشطة باستخدام علامات التبويب.",
|
||||||
|
"home.promo.cta": "نزّل أحدث إصدار للبدء.",
|
||||||
|
"home.promo.close": "إغلاق إعلان تطبيق سطح المكتب",
|
||||||
|
|
||||||
"home.hero.title": "وكيل برمجة بالذكاء الاصطناعي مفتوح المصدر",
|
"home.hero.title": "وكيل برمجة بالذكاء الاصطناعي مفتوح المصدر",
|
||||||
"home.hero.subtitle.a": "نماذج مجانية مضمّنة أو اربط أي نموذج من أي مزوّد،",
|
"home.hero.subtitle.a": "نماذج مجانية مضمّنة أو اربط أي نموذج من أي مزوّد،",
|
||||||
@@ -700,11 +704,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | تنزيل",
|
"download.title": "OpenCode | تنزيل",
|
||||||
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
|
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",
|
||||||
"download.hero.title": "تنزيل OpenCode",
|
"download.hero.title": "تنزيل OpenCode لسطح المكتب",
|
||||||
"download.hero.subtitle": "متاح في نسخة تجريبية لـ macOS، Windows، وLinux",
|
"download.hero.subtitle": "متاح في نسخة تجريبية لـ macOS، Windows، وLinux",
|
||||||
"download.hero.button": "تنزيل لـ {{os}}",
|
"download.hero.button": "تنزيل لـ {{os}}",
|
||||||
"download.section.terminal": "OpenCode للطرفية",
|
"download.section.terminal": "OpenCode للطرفية",
|
||||||
"download.section.desktop": "OpenCode لسطح المكتب (Beta)",
|
"download.section.desktop": "OpenCode لسطح المكتب",
|
||||||
"download.section.extensions": "امتدادات OpenCode",
|
"download.section.extensions": "امتدادات OpenCode",
|
||||||
"download.section.integrations": "تكاملات OpenCode",
|
"download.section.integrations": "تكاملات OpenCode",
|
||||||
"download.action.download": "تنزيل",
|
"download.action.download": "تنزيل",
|
||||||
|
|||||||
@@ -102,10 +102,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "logo opencode escuro",
|
"temp.logoDarkAlt": "logo opencode escuro",
|
||||||
|
|
||||||
"home.banner.badge": "Novo",
|
"home.banner.badge": "Novo",
|
||||||
"home.banner.text": "App desktop disponível em beta",
|
"home.banner.text": "Conheça as abas no app desktop.",
|
||||||
"home.banner.platforms": "no macOS, Windows e Linux",
|
"home.banner.platforms": "Disponível no macOS, Windows e Linux",
|
||||||
"home.banner.downloadNow": "Baixar agora",
|
"home.banner.downloadNow": "Baixar agora",
|
||||||
"home.banner.downloadBetaNow": "Baixe agora o beta do desktop",
|
"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.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,",
|
"home.hero.subtitle.a": "Modelos grátis incluídos ou conecte qualquer modelo de qualquer provedor,",
|
||||||
@@ -711,11 +715,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | Baixar",
|
"download.title": "OpenCode | Baixar",
|
||||||
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",
|
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",
|
||||||
"download.hero.title": "Baixar OpenCode",
|
"download.hero.title": "Baixar OpenCode Desktop",
|
||||||
"download.hero.subtitle": "Disponível em Beta para macOS, Windows e Linux",
|
"download.hero.subtitle": "Disponível em Beta para macOS, Windows e Linux",
|
||||||
"download.hero.button": "Baixar para {{os}}",
|
"download.hero.button": "Baixar para {{os}}",
|
||||||
"download.section.terminal": "OpenCode Terminal",
|
"download.section.terminal": "OpenCode Terminal",
|
||||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
"download.section.desktop": "OpenCode Desktop",
|
||||||
"download.section.extensions": "Extensões OpenCode",
|
"download.section.extensions": "Extensões OpenCode",
|
||||||
"download.section.integrations": "Integrações OpenCode",
|
"download.section.integrations": "Integrações OpenCode",
|
||||||
"download.action.download": "Baixar",
|
"download.action.download": "Baixar",
|
||||||
|
|||||||
@@ -102,10 +102,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "opencode logo dark",
|
"temp.logoDarkAlt": "opencode logo dark",
|
||||||
|
|
||||||
"home.banner.badge": "Ny",
|
"home.banner.badge": "Ny",
|
||||||
"home.banner.text": "Desktop-app tilgængelig i beta",
|
"home.banner.text": "Introduktion af Tabs til Desktop.",
|
||||||
"home.banner.platforms": "på macOS, Windows og Linux",
|
"home.banner.platforms": "Tilgængelig på macOS, Windows og Linux",
|
||||||
"home.banner.downloadNow": "Download nu",
|
"home.banner.downloadNow": "Download nu",
|
||||||
"home.banner.downloadBetaNow": "Download desktop-betaen 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.title": "Den open source AI-kodningsagent",
|
||||||
"home.hero.subtitle.a": "Gratis modeller inkluderet, eller forbind enhver model fra enhver udbyder,",
|
"home.hero.subtitle.a": "Gratis modeller inkluderet, eller forbind enhver model fra enhver udbyder,",
|
||||||
@@ -706,11 +710,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | Download",
|
"download.title": "OpenCode | Download",
|
||||||
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",
|
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",
|
||||||
"download.hero.title": "Download OpenCode",
|
"download.hero.title": "Download OpenCode Desktop",
|
||||||
"download.hero.subtitle": "Tilgængelig i beta til macOS, Windows og Linux",
|
"download.hero.subtitle": "Tilgængelig i beta til macOS, Windows og Linux",
|
||||||
"download.hero.button": "Download til {{os}}",
|
"download.hero.button": "Download til {{os}}",
|
||||||
"download.section.terminal": "OpenCode Terminal",
|
"download.section.terminal": "OpenCode Terminal",
|
||||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
"download.section.desktop": "OpenCode Desktop",
|
||||||
"download.section.extensions": "OpenCode Extensions",
|
"download.section.extensions": "OpenCode Extensions",
|
||||||
"download.section.integrations": "OpenCode Integrations",
|
"download.section.integrations": "OpenCode Integrations",
|
||||||
"download.action.download": "Download",
|
"download.action.download": "Download",
|
||||||
|
|||||||
@@ -102,10 +102,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "OpenCode Logo dunkel",
|
"temp.logoDarkAlt": "OpenCode Logo dunkel",
|
||||||
|
|
||||||
"home.banner.badge": "Neu",
|
"home.banner.badge": "Neu",
|
||||||
"home.banner.text": "Desktop-App in der Beta verfügbar",
|
"home.banner.text": "Neu: Tabs für Desktop.",
|
||||||
"home.banner.platforms": "auf macOS, Windows und Linux",
|
"home.banner.platforms": "Verfügbar für macOS, Windows und Linux",
|
||||||
"home.banner.downloadNow": "Jetzt herunterladen",
|
"home.banner.downloadNow": "Jetzt herunterladen",
|
||||||
"home.banner.downloadBetaNow": "Desktop-Beta 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.title": "Der Open-Source AI-Coding-Agent",
|
||||||
"home.hero.subtitle.a": "Kostenlose Modelle inklusive oder verbinde jedes Modell eines beliebigen Anbieters,",
|
"home.hero.subtitle.a": "Kostenlose Modelle inklusive oder verbinde jedes Modell eines beliebigen Anbieters,",
|
||||||
@@ -711,11 +715,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | Download",
|
"download.title": "OpenCode | Download",
|
||||||
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",
|
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",
|
||||||
"download.hero.title": "OpenCode herunterladen",
|
"download.hero.title": "OpenCode Desktop herunterladen",
|
||||||
"download.hero.subtitle": "In Beta verfügbar für macOS, Windows und Linux",
|
"download.hero.subtitle": "In Beta verfügbar für macOS, Windows und Linux",
|
||||||
"download.hero.button": "Download für {{os}}",
|
"download.hero.button": "Download für {{os}}",
|
||||||
"download.section.terminal": "OpenCode Terminal",
|
"download.section.terminal": "OpenCode Terminal",
|
||||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
"download.section.desktop": "OpenCode Desktop",
|
||||||
"download.section.extensions": "OpenCode Extensions",
|
"download.section.extensions": "OpenCode Extensions",
|
||||||
"download.section.integrations": "OpenCode Integrationen",
|
"download.section.integrations": "OpenCode Integrationen",
|
||||||
"download.action.download": "Download",
|
"download.action.download": "Download",
|
||||||
|
|||||||
@@ -102,11 +102,16 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "opencode logo dark",
|
"temp.logoDarkAlt": "opencode logo dark",
|
||||||
|
|
||||||
"home.banner.badge": "New",
|
"home.banner.badge": "New",
|
||||||
"home.banner.text": "Desktop app available in beta",
|
"home.banner.text": "Introducing tabs for desktop.",
|
||||||
"home.banner.platforms": "on macOS, Windows, and Linux",
|
"home.banner.platforms": "Available on macOS, Windows, and Linux",
|
||||||
"home.banner.downloadNow": "Download now",
|
"home.banner.downloadNow": "Download now",
|
||||||
"home.banner.downloadBetaNow": "Download the desktop beta 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.title": "The open source AI coding agent",
|
||||||
"home.hero.subtitle.a": "Free models included or connect any model from any provider,",
|
"home.hero.subtitle.a": "Free models included or connect any model from any provider,",
|
||||||
"home.hero.subtitle.b": "including Claude, GPT, Gemini and more.",
|
"home.hero.subtitle.b": "including Claude, GPT, Gemini and more.",
|
||||||
@@ -703,11 +708,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | Download",
|
"download.title": "OpenCode | Download",
|
||||||
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
|
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",
|
||||||
"download.hero.title": "Download OpenCode",
|
"download.hero.title": "Download OpenCode Desktop",
|
||||||
"download.hero.subtitle": "Available in Beta for macOS, Windows, and Linux",
|
"download.hero.subtitle": "Available in Beta for macOS, Windows, and Linux",
|
||||||
"download.hero.button": "Download for {{os}}",
|
"download.hero.button": "Download for {{os}}",
|
||||||
"download.section.terminal": "OpenCode Terminal",
|
"download.section.terminal": "OpenCode Terminal",
|
||||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
"download.section.desktop": "OpenCode Desktop",
|
||||||
"download.section.extensions": "OpenCode Extensions",
|
"download.section.extensions": "OpenCode Extensions",
|
||||||
"download.section.integrations": "OpenCode Integrations",
|
"download.section.integrations": "OpenCode Integrations",
|
||||||
"download.action.download": "Download",
|
"download.action.download": "Download",
|
||||||
|
|||||||
@@ -102,10 +102,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "logo de opencode oscuro",
|
"temp.logoDarkAlt": "logo de opencode oscuro",
|
||||||
|
|
||||||
"home.banner.badge": "Nuevo",
|
"home.banner.badge": "Nuevo",
|
||||||
"home.banner.text": "Aplicación de escritorio disponible en beta",
|
"home.banner.text": "Presentamos Tabs para Desktop.",
|
||||||
"home.banner.platforms": "en macOS, Windows y Linux",
|
"home.banner.platforms": "Disponible en macOS, Windows y Linux",
|
||||||
"home.banner.downloadNow": "Descargar ahora",
|
"home.banner.downloadNow": "Descargar ahora",
|
||||||
"home.banner.downloadBetaNow": "Descargar la beta de escritorio 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.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,",
|
"home.hero.subtitle.a": "Modelos gratuitos incluidos o conecta cualquier modelo de cualquier proveedor,",
|
||||||
@@ -711,11 +715,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | Descargar",
|
"download.title": "OpenCode | Descargar",
|
||||||
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
|
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",
|
||||||
"download.hero.title": "Descargar OpenCode",
|
"download.hero.title": "Descargar OpenCode Desktop",
|
||||||
"download.hero.subtitle": "Disponible en Beta para macOS, Windows y Linux",
|
"download.hero.subtitle": "Disponible en Beta para macOS, Windows y Linux",
|
||||||
"download.hero.button": "Descargar para {{os}}",
|
"download.hero.button": "Descargar para {{os}}",
|
||||||
"download.section.terminal": "OpenCode Terminal",
|
"download.section.terminal": "OpenCode Terminal",
|
||||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
"download.section.desktop": "OpenCode Desktop",
|
||||||
"download.section.extensions": "Extensiones OpenCode",
|
"download.section.extensions": "Extensiones OpenCode",
|
||||||
"download.section.integrations": "Integraciones OpenCode",
|
"download.section.integrations": "Integraciones OpenCode",
|
||||||
"download.action.download": "Descargar",
|
"download.action.download": "Descargar",
|
||||||
|
|||||||
@@ -100,10 +100,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "opencode logo dark",
|
"temp.logoDarkAlt": "opencode logo dark",
|
||||||
|
|
||||||
"home.banner.badge": "Nouveau",
|
"home.banner.badge": "Nouveau",
|
||||||
"home.banner.text": "Application desktop disponible en bêta",
|
"home.banner.text": "Découvrez Tabs pour Desktop.",
|
||||||
"home.banner.platforms": "sur macOS, Windows et Linux",
|
"home.banner.platforms": "Disponible sur macOS, Windows et Linux",
|
||||||
"home.banner.downloadNow": "Télécharger maintenant",
|
"home.banner.downloadNow": "Télécharger maintenant",
|
||||||
"home.banner.downloadBetaNow": "Télécharger la bêta desktop 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.title": "L'agent de code IA open source",
|
||||||
"home.hero.subtitle.a":
|
"home.hero.subtitle.a":
|
||||||
@@ -718,11 +722,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | Téléchargement",
|
"download.title": "OpenCode | Téléchargement",
|
||||||
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",
|
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",
|
||||||
"download.hero.title": "Télécharger OpenCode",
|
"download.hero.title": "Télécharger OpenCode Desktop",
|
||||||
"download.hero.subtitle": "Disponible en bêta pour macOS, Windows et Linux",
|
"download.hero.subtitle": "Disponible en bêta pour macOS, Windows et Linux",
|
||||||
"download.hero.button": "Télécharger pour {{os}}",
|
"download.hero.button": "Télécharger pour {{os}}",
|
||||||
"download.section.terminal": "OpenCode Terminal",
|
"download.section.terminal": "OpenCode Terminal",
|
||||||
"download.section.desktop": "OpenCode Desktop (Bêta)",
|
"download.section.desktop": "OpenCode Desktop",
|
||||||
"download.section.extensions": "Extensions OpenCode",
|
"download.section.extensions": "Extensions OpenCode",
|
||||||
"download.section.integrations": "Intégrations OpenCode",
|
"download.section.integrations": "Intégrations OpenCode",
|
||||||
"download.action.download": "Télécharger",
|
"download.action.download": "Télécharger",
|
||||||
|
|||||||
@@ -102,10 +102,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "logo scuro di opencode",
|
"temp.logoDarkAlt": "logo scuro di opencode",
|
||||||
|
|
||||||
"home.banner.badge": "Nuovo",
|
"home.banner.badge": "Nuovo",
|
||||||
"home.banner.text": "App desktop disponibile in beta",
|
"home.banner.text": "Scopri Tabs per Desktop.",
|
||||||
"home.banner.platforms": "su macOS, Windows e Linux",
|
"home.banner.platforms": "Disponibile su macOS, Windows e Linux",
|
||||||
"home.banner.downloadNow": "Scarica ora",
|
"home.banner.downloadNow": "Scarica ora",
|
||||||
"home.banner.downloadBetaNow": "Scarica ora la beta desktop",
|
"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.title": "L'agente di coding IA open source",
|
||||||
"home.hero.subtitle.a": "Modelli gratuiti inclusi o collega qualsiasi modello da qualsiasi provider,",
|
"home.hero.subtitle.a": "Modelli gratuiti inclusi o collega qualsiasi modello da qualsiasi provider,",
|
||||||
@@ -709,11 +713,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | Download",
|
"download.title": "OpenCode | Download",
|
||||||
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
|
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",
|
||||||
"download.hero.title": "Scarica OpenCode",
|
"download.hero.title": "Scarica OpenCode Desktop",
|
||||||
"download.hero.subtitle": "Disponibile in Beta per macOS, Windows e Linux",
|
"download.hero.subtitle": "Disponibile in Beta per macOS, Windows e Linux",
|
||||||
"download.hero.button": "Scarica per {{os}}",
|
"download.hero.button": "Scarica per {{os}}",
|
||||||
"download.section.terminal": "OpenCode Terminal",
|
"download.section.terminal": "OpenCode Terminal",
|
||||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
"download.section.desktop": "OpenCode Desktop",
|
||||||
"download.section.extensions": "OpenCode Extensions",
|
"download.section.extensions": "OpenCode Extensions",
|
||||||
"download.section.integrations": "OpenCode Integrations",
|
"download.section.integrations": "OpenCode Integrations",
|
||||||
"download.action.download": "Scarica",
|
"download.action.download": "Scarica",
|
||||||
|
|||||||
@@ -102,10 +102,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "opencodeのロゴ(ダーク)",
|
"temp.logoDarkAlt": "opencodeのロゴ(ダーク)",
|
||||||
|
|
||||||
"home.banner.badge": "新着",
|
"home.banner.badge": "新着",
|
||||||
"home.banner.text": "デスクトップアプリのベータ版が利用可能",
|
"home.banner.text": "デスクトップ版にタブが登場。",
|
||||||
"home.banner.platforms": "macOS、Windows、Linux で",
|
"home.banner.platforms": "macOS、Windows、Linux で利用できます",
|
||||||
"home.banner.downloadNow": "今すぐダウンロード",
|
"home.banner.downloadNow": "今すぐダウンロード",
|
||||||
"home.banner.downloadBetaNow": "デスクトップベータ版を今すぐダウンロード",
|
"home.banner.downloadBetaNow": "デスクトップベータ版を今すぐダウンロード",
|
||||||
|
"home.promo.title": "デスクトップ版にタブが登場",
|
||||||
|
"home.promo.body": "タブで作業とアクティブなセッションを整理できます。",
|
||||||
|
"home.promo.cta": "始めるには最新版をダウンロードしてください。",
|
||||||
|
"home.promo.close": "デスクトップアプリのお知らせを閉じる",
|
||||||
|
|
||||||
"home.hero.title": "オープンソースのAIコーディングエージェント",
|
"home.hero.title": "オープンソースのAIコーディングエージェント",
|
||||||
"home.hero.subtitle.a": "無料モデルが含まれています。また、任意のプロバイダーの任意のモデルに接続でき、",
|
"home.hero.subtitle.a": "無料モデルが含まれています。また、任意のプロバイダーの任意のモデルに接続でき、",
|
||||||
@@ -708,11 +712,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | ダウンロード",
|
"download.title": "OpenCode | ダウンロード",
|
||||||
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
|
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",
|
||||||
"download.hero.title": "OpenCode をダウンロード",
|
"download.hero.title": "OpenCode デスクトップ版をダウンロード",
|
||||||
"download.hero.subtitle": "macOS、Windows、Linux 向けベータ版を利用可能",
|
"download.hero.subtitle": "macOS、Windows、Linux 向けベータ版を利用可能",
|
||||||
"download.hero.button": "{{os}} 向けダウンロード",
|
"download.hero.button": "{{os}} 向けダウンロード",
|
||||||
"download.section.terminal": "OpenCode Terminal",
|
"download.section.terminal": "OpenCode Terminal",
|
||||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
"download.section.desktop": "OpenCode Desktop",
|
||||||
"download.section.extensions": "OpenCode Extensions",
|
"download.section.extensions": "OpenCode Extensions",
|
||||||
"download.section.integrations": "OpenCode Integrations",
|
"download.section.integrations": "OpenCode Integrations",
|
||||||
"download.action.download": "ダウンロード",
|
"download.action.download": "ダウンロード",
|
||||||
|
|||||||
@@ -102,10 +102,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "opencode 어두운 로고",
|
"temp.logoDarkAlt": "opencode 어두운 로고",
|
||||||
|
|
||||||
"home.banner.badge": "신규",
|
"home.banner.badge": "신규",
|
||||||
"home.banner.text": "데스크톱 앱 베타 버전 출시",
|
"home.banner.text": "데스크톱 탭을 소개합니다.",
|
||||||
"home.banner.platforms": "macOS, Windows, Linux 지원",
|
"home.banner.platforms": "macOS, Windows, Linux에서 사용할 수 있습니다",
|
||||||
"home.banner.downloadNow": "지금 다운로드",
|
"home.banner.downloadNow": "지금 다운로드",
|
||||||
"home.banner.downloadBetaNow": "데스크톱 베타 다운로드",
|
"home.banner.downloadBetaNow": "데스크톱 베타 다운로드",
|
||||||
|
"home.promo.title": "데스크톱 탭을 소개합니다",
|
||||||
|
"home.promo.body": "탭으로 작업과 활성 세션을 정리하세요.",
|
||||||
|
"home.promo.cta": "최신 버전을 다운로드하여 시작하세요.",
|
||||||
|
"home.promo.close": "데스크톱 앱 안내 닫기",
|
||||||
|
|
||||||
"home.hero.title": "오픈 소스 AI 코딩 에이전트",
|
"home.hero.title": "오픈 소스 AI 코딩 에이전트",
|
||||||
"home.hero.subtitle.a": "무료 모델이 포함되어 있으며, 어떤 제공자의 모델이든 연결 가능합니다.",
|
"home.hero.subtitle.a": "무료 모델이 포함되어 있으며, 어떤 제공자의 모델이든 연결 가능합니다.",
|
||||||
@@ -700,11 +704,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | 다운로드",
|
"download.title": "OpenCode | 다운로드",
|
||||||
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
|
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",
|
||||||
"download.hero.title": "OpenCode 다운로드",
|
"download.hero.title": "OpenCode 데스크톱 다운로드",
|
||||||
"download.hero.subtitle": "macOS, Windows, Linux용 베타 버전 사용 가능",
|
"download.hero.subtitle": "macOS, Windows, Linux용 베타 버전 사용 가능",
|
||||||
"download.hero.button": "{{os}}용 다운로드",
|
"download.hero.button": "{{os}}용 다운로드",
|
||||||
"download.section.terminal": "OpenCode 터미널",
|
"download.section.terminal": "OpenCode 터미널",
|
||||||
"download.section.desktop": "OpenCode 데스크톱 (베타)",
|
"download.section.desktop": "OpenCode 데스크톱",
|
||||||
"download.section.extensions": "OpenCode 확장 프로그램",
|
"download.section.extensions": "OpenCode 확장 프로그램",
|
||||||
"download.section.integrations": "OpenCode 통합",
|
"download.section.integrations": "OpenCode 통합",
|
||||||
"download.action.download": "다운로드",
|
"download.action.download": "다운로드",
|
||||||
|
|||||||
@@ -102,10 +102,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "opencode logo mørk",
|
"temp.logoDarkAlt": "opencode logo mørk",
|
||||||
|
|
||||||
"home.banner.badge": "Ny",
|
"home.banner.badge": "Ny",
|
||||||
"home.banner.text": "Desktop-app tilgjengelig i beta",
|
"home.banner.text": "Vi introduserer Tabs for Desktop.",
|
||||||
"home.banner.platforms": "på macOS, Windows og Linux",
|
"home.banner.platforms": "Tilgjengelig på macOS, Windows og Linux",
|
||||||
"home.banner.downloadNow": "Last ned nå",
|
"home.banner.downloadNow": "Last ned nå",
|
||||||
"home.banner.downloadBetaNow": "Last ned desktop-betaen 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.title": "Den åpne kildekode AI-kodingsagenten",
|
||||||
"home.hero.subtitle.a":
|
"home.hero.subtitle.a":
|
||||||
@@ -707,11 +711,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | Last ned",
|
"download.title": "OpenCode | Last ned",
|
||||||
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",
|
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",
|
||||||
"download.hero.title": "Last ned OpenCode",
|
"download.hero.title": "Last ned OpenCode Desktop",
|
||||||
"download.hero.subtitle": "Tilgjengelig i beta for macOS, Windows og Linux",
|
"download.hero.subtitle": "Tilgjengelig i beta for macOS, Windows og Linux",
|
||||||
"download.hero.button": "Last ned for {{os}}",
|
"download.hero.button": "Last ned for {{os}}",
|
||||||
"download.section.terminal": "OpenCode Terminal",
|
"download.section.terminal": "OpenCode Terminal",
|
||||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
"download.section.desktop": "OpenCode Desktop",
|
||||||
"download.section.extensions": "OpenCode Extensions",
|
"download.section.extensions": "OpenCode Extensions",
|
||||||
"download.section.integrations": "OpenCode Integrations",
|
"download.section.integrations": "OpenCode Integrations",
|
||||||
"download.action.download": "Last ned",
|
"download.action.download": "Last ned",
|
||||||
|
|||||||
@@ -101,10 +101,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "ciemne logo opencode",
|
"temp.logoDarkAlt": "ciemne logo opencode",
|
||||||
|
|
||||||
"home.banner.badge": "Nowość",
|
"home.banner.badge": "Nowość",
|
||||||
"home.banner.text": "Aplikacja desktopowa dostępna w wersji beta",
|
"home.banner.text": "Przedstawiamy karty w aplikacji desktopowej.",
|
||||||
"home.banner.platforms": "na macOS, Windows i Linux",
|
"home.banner.platforms": "Dostępne na macOS, Windows i Linux",
|
||||||
"home.banner.downloadNow": "Pobierz teraz",
|
"home.banner.downloadNow": "Pobierz teraz",
|
||||||
"home.banner.downloadBetaNow": "Pobierz betę wersji desktopowej",
|
"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.title": "Open source'owy agent AI do kodowania",
|
||||||
"home.hero.subtitle.a": "Darmowe modele w zestawie lub podłącz dowolny model od dowolnego dostawcy,",
|
"home.hero.subtitle.a": "Darmowe modele w zestawie lub podłącz dowolny model od dowolnego dostawcy,",
|
||||||
@@ -708,11 +712,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | Pobierz",
|
"download.title": "OpenCode | Pobierz",
|
||||||
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",
|
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",
|
||||||
"download.hero.title": "Pobierz OpenCode",
|
"download.hero.title": "Pobierz OpenCode Desktop",
|
||||||
"download.hero.subtitle": "Dostępne w wersji Beta na macOS, Windows i Linux",
|
"download.hero.subtitle": "Dostępne w wersji Beta na macOS, Windows i Linux",
|
||||||
"download.hero.button": "Pobierz na {{os}}",
|
"download.hero.button": "Pobierz na {{os}}",
|
||||||
"download.section.terminal": "Terminal OpenCode",
|
"download.section.terminal": "Terminal OpenCode",
|
||||||
"download.section.desktop": "Pulpit OpenCode (Beta)",
|
"download.section.desktop": "Pulpit OpenCode",
|
||||||
"download.section.extensions": "Rozszerzenia OpenCode",
|
"download.section.extensions": "Rozszerzenia OpenCode",
|
||||||
"download.section.integrations": "Integracje OpenCode",
|
"download.section.integrations": "Integracje OpenCode",
|
||||||
"download.action.download": "Pobierz",
|
"download.action.download": "Pobierz",
|
||||||
|
|||||||
@@ -102,10 +102,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "темный логотип opencode",
|
"temp.logoDarkAlt": "темный логотип opencode",
|
||||||
|
|
||||||
"home.banner.badge": "Новое",
|
"home.banner.badge": "Новое",
|
||||||
"home.banner.text": "Доступно десктопное приложение (бета)",
|
"home.banner.text": "Представляем вкладки в десктопном приложении.",
|
||||||
"home.banner.platforms": "на macOS, Windows и Linux",
|
"home.banner.platforms": "Доступно на macOS, Windows и Linux",
|
||||||
"home.banner.downloadNow": "Скачать",
|
"home.banner.downloadNow": "Скачать",
|
||||||
"home.banner.downloadBetaNow": "Скачать бету для десктопа",
|
"home.banner.downloadBetaNow": "Скачать бету для десктопа",
|
||||||
|
"home.promo.title": "Представляем вкладки в десктопном приложении",
|
||||||
|
"home.promo.body": "Организуйте работу и активные сессии с помощью вкладок.",
|
||||||
|
"home.promo.cta": "Скачайте последнюю версию, чтобы начать.",
|
||||||
|
"home.promo.close": "Закрыть объявление о десктопном приложении",
|
||||||
|
|
||||||
"home.hero.title": "AI-агент с открытым кодом для программирования",
|
"home.hero.title": "AI-агент с открытым кодом для программирования",
|
||||||
"home.hero.subtitle.a": "Бесплатные модели включены, или подключите любую модель от любого провайдера,",
|
"home.hero.subtitle.a": "Бесплатные модели включены, или подключите любую модель от любого провайдера,",
|
||||||
@@ -716,11 +720,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | Скачать",
|
"download.title": "OpenCode | Скачать",
|
||||||
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",
|
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",
|
||||||
"download.hero.title": "Скачать OpenCode",
|
"download.hero.title": "Скачать OpenCode Desktop",
|
||||||
"download.hero.subtitle": "Доступна бета для macOS, Windows и Linux",
|
"download.hero.subtitle": "Доступна бета для macOS, Windows и Linux",
|
||||||
"download.hero.button": "Скачать для {{os}}",
|
"download.hero.button": "Скачать для {{os}}",
|
||||||
"download.section.terminal": "OpenCode Terminal",
|
"download.section.terminal": "OpenCode Terminal",
|
||||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
"download.section.desktop": "OpenCode Desktop",
|
||||||
"download.section.extensions": "Расширения OpenCode",
|
"download.section.extensions": "Расширения OpenCode",
|
||||||
"download.section.integrations": "Интеграции OpenCode",
|
"download.section.integrations": "Интеграции OpenCode",
|
||||||
"download.action.download": "Скачать",
|
"download.action.download": "Скачать",
|
||||||
|
|||||||
@@ -102,10 +102,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "โลโก้ opencode แบบมืด",
|
"temp.logoDarkAlt": "โลโก้ opencode แบบมืด",
|
||||||
|
|
||||||
"home.banner.badge": "ใหม่",
|
"home.banner.badge": "ใหม่",
|
||||||
"home.banner.text": "แอปเดสก์ท็อปพร้อมใช้งานในเวอร์ชันเบต้า",
|
"home.banner.text": "ขอแนะนำแท็บสำหรับเดสก์ท็อป.",
|
||||||
"home.banner.platforms": "บน macOS, Windows และ Linux",
|
"home.banner.platforms": "พร้อมใช้งานบน macOS, Windows และ Linux",
|
||||||
"home.banner.downloadNow": "ดาวน์โหลดตอนนี้",
|
"home.banner.downloadNow": "ดาวน์โหลดตอนนี้",
|
||||||
"home.banner.downloadBetaNow": "ดาวน์โหลดเบต้าเดสก์ท็อปตอนนี้",
|
"home.banner.downloadBetaNow": "ดาวน์โหลดเบต้าเดสก์ท็อปตอนนี้",
|
||||||
|
"home.promo.title": "ขอแนะนำแท็บสำหรับเดสก์ท็อป",
|
||||||
|
"home.promo.body": "จัดระเบียบงานและเซสชันที่ใช้งานอยู่ด้วยแท็บ",
|
||||||
|
"home.promo.cta": "ดาวน์โหลดเวอร์ชันล่าสุดเพื่อเริ่มต้นใช้งาน",
|
||||||
|
"home.promo.close": "ปิดประกาศเกี่ยวกับแอปเดสก์ท็อป",
|
||||||
|
|
||||||
"home.hero.title": "เอเจนต์เขียนโค้ดด้วย AI แบบโอเพนซอร์ส",
|
"home.hero.title": "เอเจนต์เขียนโค้ดด้วย AI แบบโอเพนซอร์ส",
|
||||||
"home.hero.subtitle.a": "มีโมเดลฟรีรวมอยู่ หรือเชื่อมต่อโมเดลใดก็ได้จากผู้ให้บริการรายใดก็ได้,",
|
"home.hero.subtitle.a": "มีโมเดลฟรีรวมอยู่ หรือเชื่อมต่อโมเดลใดก็ได้จากผู้ให้บริการรายใดก็ได้,",
|
||||||
@@ -703,11 +707,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | ดาวน์โหลด",
|
"download.title": "OpenCode | ดาวน์โหลด",
|
||||||
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",
|
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",
|
||||||
"download.hero.title": "ดาวน์โหลด OpenCode",
|
"download.hero.title": "ดาวน์โหลด OpenCode สำหรับเดสก์ท็อป",
|
||||||
"download.hero.subtitle": "พร้อมใช้งานในเวอร์ชันเบต้าสำหรับ macOS, Windows และ Linux",
|
"download.hero.subtitle": "พร้อมใช้งานในเวอร์ชันเบต้าสำหรับ macOS, Windows และ Linux",
|
||||||
"download.hero.button": "ดาวน์โหลดสำหรับ {{os}}",
|
"download.hero.button": "ดาวน์โหลดสำหรับ {{os}}",
|
||||||
"download.section.terminal": "OpenCode Terminal",
|
"download.section.terminal": "OpenCode Terminal",
|
||||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
"download.section.desktop": "OpenCode Desktop",
|
||||||
"download.section.extensions": "ส่วนขยาย OpenCode",
|
"download.section.extensions": "ส่วนขยาย OpenCode",
|
||||||
"download.section.integrations": "การเชื่อมต่อ OpenCode",
|
"download.section.integrations": "การเชื่อมต่อ OpenCode",
|
||||||
"download.action.download": "ดาวน์โหลด",
|
"download.action.download": "ดาวน์โหลด",
|
||||||
|
|||||||
@@ -102,10 +102,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "opencode koyu logo",
|
"temp.logoDarkAlt": "opencode koyu logo",
|
||||||
|
|
||||||
"home.banner.badge": "Yeni",
|
"home.banner.badge": "Yeni",
|
||||||
"home.banner.text": "Masaüstü uygulaması beta olarak mevcut",
|
"home.banner.text": "Masaüstü uygulamasında sekmelerle tanışın.",
|
||||||
"home.banner.platforms": "macOS, Windows ve Linux'ta",
|
"home.banner.platforms": "macOS, Windows ve Linux'ta kullanılabilir",
|
||||||
"home.banner.downloadNow": "Şimdi indir",
|
"home.banner.downloadNow": "Şimdi indir",
|
||||||
"home.banner.downloadBetaNow": "Masaüstü betayı ş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.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,",
|
"home.hero.subtitle.a": "Ücretsiz modeller dahil veya herhangi bir sağlayıcıdan herhangi bir modeli bağlayın,",
|
||||||
@@ -711,11 +715,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | İndir",
|
"download.title": "OpenCode | İndir",
|
||||||
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",
|
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",
|
||||||
"download.hero.title": "OpenCode'u İndir",
|
"download.hero.title": "OpenCode Desktop'u İndir",
|
||||||
"download.hero.subtitle": "macOS, Windows ve Linux için Beta olarak sunuluyor",
|
"download.hero.subtitle": "macOS, Windows ve Linux için Beta olarak sunuluyor",
|
||||||
"download.hero.button": "{{os}} için indir",
|
"download.hero.button": "{{os}} için indir",
|
||||||
"download.section.terminal": "OpenCode Terminal",
|
"download.section.terminal": "OpenCode Terminal",
|
||||||
"download.section.desktop": "OpenCode Desktop (Beta)",
|
"download.section.desktop": "OpenCode Desktop",
|
||||||
"download.section.extensions": "OpenCode Eklentileri",
|
"download.section.extensions": "OpenCode Eklentileri",
|
||||||
"download.section.integrations": "OpenCode Entegrasyonları",
|
"download.section.integrations": "OpenCode Entegrasyonları",
|
||||||
"download.action.download": "İndir",
|
"download.action.download": "İndir",
|
||||||
|
|||||||
@@ -102,10 +102,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "темний логотип opencode",
|
"temp.logoDarkAlt": "темний логотип opencode",
|
||||||
|
|
||||||
"home.banner.badge": "Нове",
|
"home.banner.badge": "Нове",
|
||||||
"home.banner.text": "Десктопний застосунок доступний у бета-версії",
|
"home.banner.text": "Представляємо вкладки в десктопному застосунку.",
|
||||||
"home.banner.platforms": "на macOS, Windows та Linux",
|
"home.banner.platforms": "Доступно на macOS, Windows та Linux",
|
||||||
"home.banner.downloadNow": "Завантажити зараз",
|
"home.banner.downloadNow": "Завантажити зараз",
|
||||||
"home.banner.downloadBetaNow": "Завантажити бета-версію десктопного застосунку",
|
"home.banner.downloadBetaNow": "Завантажити бета-версію десктопного застосунку",
|
||||||
|
"home.promo.title": "Представляємо вкладки в десктопному застосунку",
|
||||||
|
"home.promo.body": "Упорядковуйте роботу й активні сесії за допомогою вкладок.",
|
||||||
|
"home.promo.cta": "Завантажте останню версію, щоб почати.",
|
||||||
|
"home.promo.close": "Закрити оголошення про десктопний застосунок",
|
||||||
|
|
||||||
"home.hero.title": "Відкритий AI-агент для кодування",
|
"home.hero.title": "Відкритий AI-агент для кодування",
|
||||||
"home.hero.subtitle.a": "Безкоштовні моделі включено або підключіть будь-яку модель від будь-якого провайдера,",
|
"home.hero.subtitle.a": "Безкоштовні моделі включено або підключіть будь-яку модель від будь-якого провайдера,",
|
||||||
@@ -670,11 +674,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | Завантажити",
|
"download.title": "OpenCode | Завантажити",
|
||||||
"download.meta.description": "Завантажте OpenCode для macOS, Windows та Linux",
|
"download.meta.description": "Завантажте OpenCode для macOS, Windows та Linux",
|
||||||
"download.hero.title": "Завантажити OpenCode",
|
"download.hero.title": "Завантажити OpenCode Desktop",
|
||||||
"download.hero.subtitle": "Доступно в бета-версії для macOS, Windows та Linux",
|
"download.hero.subtitle": "Доступно в бета-версії для macOS, Windows та Linux",
|
||||||
"download.hero.button": "Завантажити для {{os}}",
|
"download.hero.button": "Завантажити для {{os}}",
|
||||||
"download.section.terminal": "Термінал OpenCode",
|
"download.section.terminal": "Термінал OpenCode",
|
||||||
"download.section.desktop": "Десктоп OpenCode (Бета)",
|
"download.section.desktop": "Десктоп OpenCode",
|
||||||
"download.section.extensions": "Розширення OpenCode",
|
"download.section.extensions": "Розширення OpenCode",
|
||||||
"download.section.integrations": "Інтеграції OpenCode",
|
"download.section.integrations": "Інтеграції OpenCode",
|
||||||
"download.action.download": "Завантажити",
|
"download.action.download": "Завантажити",
|
||||||
|
|||||||
@@ -103,10 +103,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "opencode logo 暗色",
|
"temp.logoDarkAlt": "opencode logo 暗色",
|
||||||
|
|
||||||
"home.banner.badge": "新",
|
"home.banner.badge": "新",
|
||||||
"home.banner.text": "桌面应用 Beta 版现已推出",
|
"home.banner.text": "桌面版标签页现已推出。",
|
||||||
"home.banner.platforms": "支持 macOS, Windows, 和 Linux",
|
"home.banner.platforms": "适用于 macOS、Windows 和 Linux",
|
||||||
"home.banner.downloadNow": "立即下载",
|
"home.banner.downloadNow": "立即下载",
|
||||||
"home.banner.downloadBetaNow": "立即下载桌面 Beta 版",
|
"home.banner.downloadBetaNow": "立即下载桌面 Beta 版",
|
||||||
|
"home.promo.title": "桌面版标签页现已推出",
|
||||||
|
"home.promo.body": "使用标签页整理工作和活跃会话。",
|
||||||
|
"home.promo.cta": "下载最新版本即可开始使用。",
|
||||||
|
"home.promo.close": "关闭桌面应用公告",
|
||||||
|
|
||||||
"home.hero.title": "开源 AI 编程代理",
|
"home.hero.title": "开源 AI 编程代理",
|
||||||
"home.hero.subtitle.a": "内置免费模型,或连接任意提供商的任意模型,",
|
"home.hero.subtitle.a": "内置免费模型,或连接任意提供商的任意模型,",
|
||||||
@@ -682,11 +686,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | 下载",
|
"download.title": "OpenCode | 下载",
|
||||||
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 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.subtitle": "适用于 macOS, Windows, 和 Linux 的 Beta 版",
|
||||||
"download.hero.button": "下载 {{os}} 版",
|
"download.hero.button": "下载 {{os}} 版",
|
||||||
"download.section.terminal": "OpenCode 终端",
|
"download.section.terminal": "OpenCode 终端",
|
||||||
"download.section.desktop": "OpenCode 桌面版 (Beta)",
|
"download.section.desktop": "OpenCode 桌面版",
|
||||||
"download.section.extensions": "OpenCode 扩展",
|
"download.section.extensions": "OpenCode 扩展",
|
||||||
"download.section.integrations": "OpenCode 集成",
|
"download.section.integrations": "OpenCode 集成",
|
||||||
"download.action.download": "下载",
|
"download.action.download": "下载",
|
||||||
|
|||||||
@@ -103,10 +103,14 @@ export const dict = {
|
|||||||
"temp.logoDarkAlt": "opencode 深色標誌",
|
"temp.logoDarkAlt": "opencode 深色標誌",
|
||||||
|
|
||||||
"home.banner.badge": "新",
|
"home.banner.badge": "新",
|
||||||
"home.banner.text": "桌面應用已推出 Beta",
|
"home.banner.text": "桌面版分頁功能全新推出。",
|
||||||
"home.banner.platforms": "支援 macOS、Windows 與 Linux",
|
"home.banner.platforms": "適用於 macOS、Windows 與 Linux",
|
||||||
"home.banner.downloadNow": "立即下載",
|
"home.banner.downloadNow": "立即下載",
|
||||||
"home.banner.downloadBetaNow": "立即下載桌面 Beta 版",
|
"home.banner.downloadBetaNow": "立即下載桌面 Beta 版",
|
||||||
|
"home.promo.title": "桌面版分頁功能全新推出",
|
||||||
|
"home.promo.body": "使用分頁整理工作和作用中的工作階段。",
|
||||||
|
"home.promo.cta": "下載最新版本即可開始使用。",
|
||||||
|
"home.promo.close": "關閉桌面應用程式公告",
|
||||||
|
|
||||||
"home.hero.title": "開源 AI 編碼代理",
|
"home.hero.title": "開源 AI 編碼代理",
|
||||||
"home.hero.subtitle.a": "內建免費模型,或連接任意供應商的任意模型,",
|
"home.hero.subtitle.a": "內建免費模型,或連接任意供應商的任意模型,",
|
||||||
@@ -682,11 +686,11 @@ export const dict = {
|
|||||||
|
|
||||||
"download.title": "OpenCode | 下載",
|
"download.title": "OpenCode | 下載",
|
||||||
"download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 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.subtitle": "適用於 macOS、Windows 與 Linux 的 Beta 版現已提供",
|
||||||
"download.hero.button": "下載 {{os}} 版",
|
"download.hero.button": "下載 {{os}} 版",
|
||||||
"download.section.terminal": "OpenCode 終端",
|
"download.section.terminal": "OpenCode 終端",
|
||||||
"download.section.desktop": "OpenCode 桌面版(Beta)",
|
"download.section.desktop": "OpenCode 桌面版",
|
||||||
"download.section.extensions": "OpenCode 擴充功能",
|
"download.section.extensions": "OpenCode 擴充功能",
|
||||||
"download.section.integrations": "OpenCode 整合",
|
"download.section.integrations": "OpenCode 整合",
|
||||||
"download.action.download": "下載",
|
"download.action.download": "下載",
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export async function statsProxy(evt: APIEvent) {
|
|||||||
if (
|
if (
|
||||||
targetUrl.pathname.startsWith(`${dataPath}/_build/`) ||
|
targetUrl.pathname.startsWith(`${dataPath}/_build/`) ||
|
||||||
targetUrl.pathname === `${dataPath}/banner.jpg` ||
|
targetUrl.pathname === `${dataPath}/banner.jpg` ||
|
||||||
targetUrl.pathname === `${dataPath}/banner.png`
|
targetUrl.pathname === `${dataPath}/banner.png` ||
|
||||||
|
targetUrl.pathname === `${dataPath}/sitemap.xml`
|
||||||
) {
|
) {
|
||||||
targetUrl.pathname = targetUrl.pathname.slice(dataPath.length)
|
targetUrl.pathname = targetUrl.pathname.slice(dataPath.length)
|
||||||
}
|
}
|
||||||
@@ -97,7 +98,8 @@ function isDataBypassPath(pathname: string) {
|
|||||||
pathname.startsWith(`${dataPath}/api/`) ||
|
pathname.startsWith(`${dataPath}/api/`) ||
|
||||||
pathname.startsWith(`${dataPath}/_server`) ||
|
pathname.startsWith(`${dataPath}/_server`) ||
|
||||||
pathname === `${dataPath}/banner.jpg` ||
|
pathname === `${dataPath}/banner.jpg` ||
|
||||||
pathname === `${dataPath}/banner.png`
|
pathname === `${dataPath}/banner.png` ||
|
||||||
|
pathname === `${dataPath}/sitemap.xml`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -284,7 +284,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-component="content"] {
|
[data-component="content"] {
|
||||||
padding: 6rem 5rem;
|
padding: 3rem 5rem 6rem;
|
||||||
|
|
||||||
@media (max-width: 60rem) {
|
@media (max-width: 60rem) {
|
||||||
padding: 4rem 1.5rem;
|
padding: 4rem 1.5rem;
|
||||||
@@ -316,67 +316,43 @@
|
|||||||
|
|
||||||
/* Download Hero Section */
|
/* Download Hero Section */
|
||||||
[data-component="download-hero"] {
|
[data-component="download-hero"] {
|
||||||
/* display: grid; */
|
display: grid;
|
||||||
display: none;
|
grid-template-columns: 255px 1fr;
|
||||||
grid-template-columns: 260px 1fr;
|
|
||||||
gap: 4rem;
|
gap: 4rem;
|
||||||
padding-bottom: 2rem;
|
padding: 4px;
|
||||||
|
border: 1px solid var(--color-border-weak);
|
||||||
|
border-radius: 8px;
|
||||||
margin-bottom: 4rem;
|
margin-bottom: 4rem;
|
||||||
|
|
||||||
@media (max-width: 50rem) {
|
@media (max-width: 50rem) {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 1.5rem;
|
gap: 0;
|
||||||
padding-bottom: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="hero-icon"] {
|
[data-component="hero-video"] {
|
||||||
display: flex;
|
video {
|
||||||
justify-content: flex-end;
|
display: block;
|
||||||
align-items: center;
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
@media (max-width: 40rem) {
|
object-fit: cover;
|
||||||
display: none;
|
border-radius: 4px;
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="icon-placeholder"] {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
background: var(--color-background-weak);
|
|
||||||
border: 1px solid var(--color-border-weak);
|
|
||||||
border-radius: 24px;
|
|
||||||
|
|
||||||
@media (max-width: 50rem) {
|
@media (max-width: 50rem) {
|
||||||
width: 80px;
|
aspect-ratio: 16 / 9;
|
||||||
height: 80px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
border-radius: 24px;
|
|
||||||
box-shadow:
|
|
||||||
0 1.467px 2.847px 0 rgba(0, 0, 0, 0.42),
|
|
||||||
0 0.779px 1.512px 0 rgba(0, 0, 0, 0.34),
|
|
||||||
0 0.324px 0.629px 0 rgba(0, 0, 0, 0.24);
|
|
||||||
|
|
||||||
@media (max-width: 50rem) {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 50rem) {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="hero-text"] {
|
[data-component="hero-text"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding-right: 3rem;
|
||||||
|
|
||||||
|
@media (max-width: 50rem) {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { LocaleLinks } from "~/component/locale-links"
|
|||||||
import { config } from "~/config"
|
import { config } from "~/config"
|
||||||
import { useI18n } from "~/context/i18n"
|
import { useI18n } from "~/context/i18n"
|
||||||
import { useLanguage } from "~/context/language"
|
import { useLanguage } from "~/context/language"
|
||||||
import desktopAppIcon from "../../asset/lander/opencode-desktop-icon.png"
|
import desktopTabsVideo from "../../asset/lander/desktop-tabs-landscape.mp4"
|
||||||
import type { DownloadPlatform } from "./types"
|
import type { DownloadPlatform } from "./types"
|
||||||
|
|
||||||
type OS = "macOS" | "Windows" | "Linux" | null
|
type OS = "macOS" | "Windows" | "Linux" | null
|
||||||
@@ -93,12 +93,14 @@ export default function Download() {
|
|||||||
|
|
||||||
<div data-component="content">
|
<div data-component="content">
|
||||||
<section data-component="download-hero">
|
<section data-component="download-hero">
|
||||||
<div data-component="hero-icon">
|
<div data-component="hero-video">
|
||||||
<img src={desktopAppIcon} alt="" />
|
<video src={desktopTabsVideo} autoplay playsinline loop muted preload="metadata" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div data-component="hero-text">
|
<div data-component="hero-text">
|
||||||
<h1>{i18n.t("download.hero.title")}</h1>
|
<h1>{i18n.t("download.hero.title")}</h1>
|
||||||
<p>{i18n.t("download.hero.subtitle")}</p>
|
<p>
|
||||||
|
{i18n.t("home.promo.body")} {i18n.t("home.promo.cta")}
|
||||||
|
</p>
|
||||||
<Show when={detectedOS()}>
|
<Show when={detectedOS()}>
|
||||||
<a
|
<a
|
||||||
href={language.route(getDownloadHref(getDownloadPlatform(detectedOS())))}
|
href={language.route(getDownloadHref(getDownloadPlatform(detectedOS())))}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.17.18",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user