Compare commits

..
Author SHA1 Message Date
Brendan Allan a7c2df0a0d Merge remote-tracking branch 'origin/v2' into shared-data-impl
# Conflicts:
#	packages/app/e2e/performance/timeline-stability/fixture.ts
#	packages/app/e2e/regression/session-timeline-collapse-state.spec.ts
#	packages/app/e2e/regression/session-timeline-context-resize.spec.ts
#	packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
#	packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts
#	packages/app/e2e/regression/session-timeline-transport.spec.ts
#	packages/app/e2e/regression/subagent-child-navigation.spec.ts
#	packages/app/e2e/utils/mock-server.ts
#	packages/app/e2e/utils/sse-transport.ts
#	packages/app/src/components/dialog-fork.tsx
#	packages/app/src/components/prompt-input/submit.test.ts
#	packages/app/src/components/prompt-input/submit.ts
#	packages/app/src/components/settings-v2/workspaces.tsx
#	packages/app/src/context/permission-auto-respond.test.ts
#	packages/app/src/context/server-session-v2-reducer.test.ts
#	packages/app/src/context/server-session.test.ts
#	packages/app/src/context/terminal.tsx
#	packages/app/src/pages/directory-layout.tsx
#	packages/app/src/pages/session.tsx
#	packages/app/src/pages/session/timeline/controller.tsx
2026-08-18 06:53:42 +08:00
Luke Parker eb22768a23 refactor(app): remove V1 client compatibility (#43045) 2026-08-18 08:03:51 +10:00
opencode-agent[bot]andLuke Parker 8644db2c2a feat(app): render Mermaid code blocks (#43024)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-18 08:02:06 +10:00
opencode-agent[bot] aca42423d3 chore: generate 2026-08-17 21:55:57 +00:00
Kit Langton d9b81d2233 chore(effect): update to beta.107 (#43109) 2026-08-17 21:54:08 +00:00
Aiden Cline 8fc65b3038 test(ai): cover Gemini parallel signatures (#43114) 2026-08-17 16:02:02 -05:00
opencode-agent[bot] f161c90057 chore: generate 2026-08-17 20:21:54 +00:00
opencode-agent[bot] 728ae7c949 fix(stats): prefer Go catalog pricing (#43120) 2026-08-17 15:20:06 -05:00
Dax 42fc297d30 feat(tui): inherit terminal environment per session (#42957) 2026-08-17 20:18:28 +00:00
Brendan Allan 667e6594ce cleanup 2026-08-17 23:06:37 +08:00
Brendan Allan d4e363e175 cleanup DirectoryLayout 2026-08-17 21:26:54 +08:00
Brendan Allan 14c2f7cab2 Merge branch 'v2' into shared-data-impl 2026-08-17 20:43:15 +08:00
Brendan Allan 01fbe4027e fix(app): open new tab from home 2026-08-17 20:41:58 +08:00
Brendan Allan 5e651053cc fix(app): complete shared data migration 2026-08-17 19:49:40 +08:00
Brendan Allan 9d4b6c6589 fix(app): show default session title 2026-08-17 18:47:40 +08:00
Brendan Allan 216b479f7a remove session lineage + fix popular providers 2026-08-17 18:14:40 +08:00
Brendan Allan 6eece351c4 fix(app): load provider settings catalogs 2026-08-17 17:17:31 +08:00
Brendan Allan 0f90793df2 fix(app): use renamed shared data API 2026-08-17 15:54:39 +08:00
Brendan Allan 46e832b1fd refactor(app): use shared server data 2026-08-17 15:54:38 +08:00
348 changed files with 10870 additions and 45686 deletions
+159 -76
View File
@@ -191,7 +191,7 @@
"solid-js": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-beta.101",
"effect": "4.0.0-beta.107",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -342,6 +342,9 @@
"version": "1.18.4",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
@@ -349,11 +352,13 @@
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0",
"@ff-labs/fff-bun": "0.10.1",
"@ff-labs/fff-node": "0.10.1",
"@lydell/node-pty": "catalog:",
@@ -373,6 +378,7 @@
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.1",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"htmlparser2": "8.0.2",
"ignore": "7.0.5",
@@ -514,7 +520,7 @@
"name": "@opencode-ai/http-recorder",
"version": "1.18.15",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.101",
"@effect/platform-node-shared": "4.0.0-beta.107",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
@@ -694,6 +700,7 @@
"fuzzysort": "catalog:",
"luxon": "catalog:",
"marked": "catalog:",
"mermaid": "11.16.1",
"morphdom": "2.7.8",
"motion": "12.34.5",
"remeda": "catalog:",
@@ -910,7 +917,7 @@
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"katex": "0.16.47",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
@@ -1047,9 +1054,9 @@
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch",
@@ -1061,6 +1068,7 @@
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
},
"overrides": {
"@effect/platform-node-shared": "catalog:",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
@@ -1071,9 +1079,10 @@
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@corvu/drawer": "0.2.4",
"@effect/opentelemetry": "4.0.0-beta.101",
"@effect/platform-node": "4.0.0-beta.101",
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
"@effect/opentelemetry": "4.0.0-beta.107",
"@effect/platform-node": "4.0.0-beta.107",
"@effect/platform-node-shared": "4.0.0-beta.107",
"@effect/sql-sqlite-bun": "4.0.0-beta.107",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
@@ -1110,7 +1119,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.101",
"effect": "4.0.0-beta.107",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"hono": "4.10.7",
@@ -1157,7 +1166,7 @@
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/openai": "3.0.67", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-PsSh7a6qW+3kQXPs1kD4wDwuZby0t1PIaB6j/1aMKmPFJ5LxcIcULLMF/bjITLt5o/8lc0t6TXIwG0zlhH7uZw=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.77", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ML8C2M1YvPA1ulEx4TiyF0k1xvC2ikEiPBIC1PPQ0a5xELUGrO2lAaEzsTEoJ+eCeDd8PSBuFJjs+r+9yIwQXA=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="],
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="],
@@ -1185,7 +1194,7 @@
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
"@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/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
"@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=="],
@@ -1307,6 +1316,8 @@
"@aws-sdk/client-athena": ["@aws-sdk/client-athena@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/credential-provider-node": "3.933.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-9eMUCu1Ay3C9ojo+dJcynSdpbxuwDVtZUt/Xhce+c2+mgDsmvRzjww+wfLpZwRNWxBWmeauQQAZk52tCwQgXsQ=="],
"@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1057.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.47", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-5MliYkp2u0+2arTp5fZIaxl+xmm90LEKv/VeSxhfNQW4t0fvWJrNO429/jchWQenNoDRrOGE59VfbuZUfwFujg=="],
"@aws-sdk/client-firehose": ["@aws-sdk/client-firehose@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/credential-provider-node": "3.933.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-tDrtgczN2lQsflLDPYu/wdOoyCZLVYtgzmWnYzSEOBWd/cp2AbuQ7D+FemSwUTzyoMTuhhIevyEJKzqsF+QYxA=="],
"@aws-sdk/client-lambda": ["@aws-sdk/client-lambda@3.1106.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/credential-provider-node": "^3.972.78", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-vxZtyCy6k9GaZ6j39zg1cNHDtAgwan/a2cQRtnaiQ4iUtX4MGxHdErKyhYAj59tkSqiKAMQpOFhIH7mb0u/9UQ=="],
@@ -1319,21 +1330,25 @@
"@aws-sdk/core": ["@aws-sdk/core@3.932.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@aws-sdk/xml-builder": "3.930.0", "@smithy/core": "^3.18.2", "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-AS8gypYQCbNojwgjvZGkJocC2CoEICDx9ZJ15ILsv+MlcCVLtUJSRSx3VzJOUY2EEIaGLRrPNlIqyn/9/fySvA=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.932.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-ozge/c7NdHUDyHqro6+P5oHt8wfKSUBN+olttiVfBe9Mw3wBMpPa3gQ0pZnG+gwBkKskBuip2bMR16tqYvUSEA=="],
"@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.66", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-i4HTkP21eHaXA+XKoc6dYxlKPvYUqbusGGIsFRcSGTF8ln/q6RrWoRdVJ6UVODTCW59Ot1mVcbJXRAEf6kxa3w=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.932.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/types": "3.930.0", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/node-http-handler": "^4.4.5", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/util-stream": "^4.5.6", "tslib": "^2.6.2" } }, "sha512-b6N9Nnlg8JInQwzBkUq5spNaXssM3h3zLxGzpPrnw0nHSIWPJPTbZzA5Ca285fcDUFuKP+qf3qkuqlAjGOdWhg=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.67", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.933.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/credential-provider-env": "3.932.0", "@aws-sdk/credential-provider-http": "3.932.0", "@aws-sdk/credential-provider-process": "3.932.0", "@aws-sdk/credential-provider-sso": "3.933.0", "@aws-sdk/credential-provider-web-identity": "3.933.0", "@aws-sdk/nested-clients": "3.933.0", "@aws-sdk/types": "3.930.0", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-HygGyKuMG5AaGXsmM0d81miWDon55xwalRHB3UmDg3QBhtunbNIoIaWUbNTKuBZXcIN6emeeEZw/YgSMqLc0YA=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.69", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.12", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/credential-provider-env": "^3.972.67", "@aws-sdk/credential-provider-http": "^3.972.69", "@aws-sdk/credential-provider-login": "^3.972.74", "@aws-sdk/credential-provider-process": "^3.972.67", "@aws-sdk/credential-provider-sso": "^3.973.11", "@aws-sdk/credential-provider-web-identity": "^3.972.73", "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.74", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.933.0", "", { "dependencies": { "@aws-sdk/credential-provider-env": "3.932.0", "@aws-sdk/credential-provider-http": "3.932.0", "@aws-sdk/credential-provider-ini": "3.933.0", "@aws-sdk/credential-provider-process": "3.932.0", "@aws-sdk/credential-provider-sso": "3.933.0", "@aws-sdk/credential-provider-web-identity": "3.933.0", "@aws-sdk/types": "3.930.0", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-L2dE0Y7iMLammQewPKNeEh1z/fdJyYEU+/QsLBD9VEh+SXcN/FIyTi21Isw8wPZN6lMB9PDVtISzBnF8HuSFrw=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.932.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-BodZYKvT4p/Dkm28Ql/FhDdS1+p51bcZeMMu2TRtU8PoMDHnVDhHz27zASEKSZwmhvquxHrZHB0IGuVqjZUtSQ=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.67", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.933.0", "", { "dependencies": { "@aws-sdk/client-sso": "3.933.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/token-providers": "3.933.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-/R1DBR7xNcuZIhS2RirU+P2o8E8/fOk+iLAhbqeSTq+g09fP/F6W7ouFpS5eVE2NIfWG7YBFoVddOhvuqpn51g=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.11", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/token-providers": "3.1103.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.933.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/nested-clients": "3.933.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-c7Eccw2lhFx2/+qJn3g+uIDWRuWi2A6Sz3PVvckFUEzPsP0dPUo19hlvtarwP5GzrsXn0yEPRVhpewsIaSCGaQ=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.73", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A=="],
"@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1057.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1057.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-cognito-identity": "^3.972.38", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-node": "^3.972.47", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-rbrEHtz11g0kxsSkYr3fx2HABNNblp4AhB2MgPvJHgYOWfJ2eBviU7Mvoaef0PW8QH6lbZDfJcnM7eKvtvz3sw=="],
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.930.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-cnCLWeKPYgvV4yRYPFH6pWMdUByvu2cy2BAlfsPpvnm4RaVioztyvxmQj5PmVN5fvWs5w/2d6U7le8X9iye2sA=="],
@@ -1355,13 +1370,13 @@
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.932.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@smithy/core": "^3.18.2", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-9BGTbJyA/4PTdwQWE9hAFIJGpsYkyEW20WON3i15aDqo5oRZwZmqaVageOD57YYqG8JDJjvcwKyDdR4cc38dvg=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-o1GX0+IPlFi/D8ei9y/jj3yucJWNfPnbB5appVBWevAyUdZA5KzQ2nK/hDxiu9olTZlFEFpf1m1Rn3FaGxHqsw=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.41", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/signature-v4-multi-region": "^3.996.43", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ=="],
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.930.0", "", { "dependencies": { "@aws-sdk/types": "3.930.0", "@smithy/config-resolver": "^4.4.3", "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-KL2JZqH6aYeQssu1g1KuWsReupdfOoxD6f1as2VC+rdwYFUu4LfzMsFfXnBvvQWWqQ7rZHWOw1T+o5gJmg7Dzw=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.932.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "3.932.0", "@aws-sdk/types": "3.930.0", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-NCIRJvoRc9246RZHIusY1+n/neeG2yGhBGdKhghmrNdM+mLLN6Ii7CKFZjx3DhxtpHMpl1HWLTMhdVrGwP2upw=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.933.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/nested-clients": "3.933.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Qzq7zj9yXUgAAJEbbmqRhm0jmUndl8nHG0AbxFEfCfQRVZWL96Qzx0mf8lYwT9hIMrXncLwy31HOthmbXwFRwQ=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1103.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ=="],
"@aws-sdk/types": ["@aws-sdk/types@3.930.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-we/vaAgwlEFW7IeftmCLlLMw+6hFs3DzZPJw7lVHbj/5HJ0bz9gndxEsS2lQoeJ1zhiiLqAqvXxmM43s0MBg0A=="],
@@ -1551,13 +1566,13 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.101", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.101" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-IdejlqRLbjRHJgVnea4s8CxTWfvkSjM0HlnpNfP07IGTbhmAvPs7PMaWt1xzYWbOnI7CriQwxv+54eW1PwIkZg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.107", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-beta.107" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-WxR3OEcwVtckNYGxvERA4kiS8cb2B46lSWxQw8P6dCCzW0j0VC7hkWyzryJ16MVXfI/5xQHS3r5j9mud+JVvsg=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.101", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.101", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101", "ioredis": "^5.7.0" } }, "sha512-pClk7dmMtHgM6Byu7CzGfrPvZ1/4BwmrRlCg2Op+iJozMkwVUxq8v4beK8b9SvxsliJjHZFznjvkVLX7LQjBqw=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.107", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.107", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.107", "ioredis": ">=5.7.0 <6.0.0" } }, "sha512-k+6YNbV4Ck0L6YXtlgkvEnuP5tlxWD8EeWOrpn46PDqbGEwt4ONpRltTwm3tn2cyBXD0i+2P11cUH/6sdFagTA=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.101", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-g4L7XiyJSNJLJVhlslyg2zBCQsoKQf1y1gd+Yfd+3wD9ymC+m7ymbd/5FGqnT1aXV6E2AwRr4D/R1eyRUikvWQ=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.107", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.107" } }, "sha512-y6BqcRi86BfTJv+tvDrob4ozYVHxxlHYcn/zIQqZjXI9CvKnkgD6ng+38G1o45c4f2ucU+6HRI9POCmFdMoVGA=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.101", "", { "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-s6AC7LXCEjCN+nKegKFY4MOi6bmT1+SLR9YHEYwhY3P5qyQQB4R5yYLgt+3J4EPp5fa3t4FIDdPEUwz9LdKm6g=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.107", "", { "peerDependencies": { "effect": "^4.0.0-beta.107" } }, "sha512-BuSSCUoXz6JSR30wT4Y0ukmJKvIFhm/gROwEeA0nLO5QYf33CdTunwX+q35+/MNOOLT8jL3xaDsz5R5aAF1vYQ=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -3859,7 +3874,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="],
"effect": ["effect@4.0.0-beta.107", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "uuid": "^14.0.1" } }, "sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -4061,8 +4076,6 @@
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="],
@@ -4313,7 +4326,7 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
"ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
@@ -4511,7 +4524,7 @@
"jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="],
"katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="],
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@@ -4821,8 +4834,6 @@
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
"multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="],
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
"mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="],
@@ -5629,8 +5640,6 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="],
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.8", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-b+5ynEFp4Woe5a22hzNQm42lD23t13ZMihVxHbzjA50zdcM9aOSJTIjdJ0PDSd4/50HbBXcpHiQsz6rM4N88ww=="],
"topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="],
@@ -5965,8 +5974,6 @@
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/amazon-bedrock/@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=="],
@@ -5975,6 +5982,8 @@
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
@@ -6011,6 +6020,8 @@
"@ai-sdk/google/@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/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.77", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ML8C2M1YvPA1ulEx4TiyF0k1xvC2ikEiPBIC1PPQ0a5xELUGrO2lAaEzsTEoJ+eCeDd8PSBuFJjs+r+9yIwQXA=="],
"@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
"@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
@@ -6023,9 +6034,9 @@
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "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-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@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=="],
"@ai-sdk/openai/@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/perplexity/@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=="],
@@ -6111,6 +6122,12 @@
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-sdk/client-cognito-identity/@aws-sdk/core": ["@aws-sdk/core@3.977.6", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw=="],
"@aws-sdk/client-cognito-identity/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.78", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.67", "@aws-sdk/credential-provider-http": "^3.972.69", "@aws-sdk/credential-provider-ini": "^3.973.12", "@aws-sdk/credential-provider-process": "^3.972.67", "@aws-sdk/credential-provider-sso": "^3.973.11", "@aws-sdk/credential-provider-web-identity": "^3.972.73", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng=="],
"@aws-sdk/client-cognito-identity/@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="],
"@aws-sdk/client-lambda/@aws-sdk/core": ["@aws-sdk/core@3.977.6", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.78", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.67", "@aws-sdk/credential-provider-http": "^3.972.69", "@aws-sdk/credential-provider-ini": "^3.973.12", "@aws-sdk/credential-provider-process": "^3.972.67", "@aws-sdk/credential-provider-sso": "^3.973.11", "@aws-sdk/credential-provider-web-identity": "^3.972.73", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng=="],
@@ -6139,12 +6156,64 @@
"@aws-sdk/client-sts/@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.782.0", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "3.782.0", "@aws-sdk/types": "3.775.0", "@smithy/node-config-provider": "^4.0.2", "@smithy/types": "^4.2.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-dMFkUBgh2Bxuw8fYZQoH/u3H4afQ12VSkzEi//qFiDTwbKYq+u+RYjc8GLDM6JSK1BShMu5AVR7HD4ap1TYUnA=="],
"@aws-sdk/credential-provider-cognito-identity/@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="],
"@aws-sdk/credential-provider-env/@aws-sdk/core": ["@aws-sdk/core@3.977.6", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw=="],
"@aws-sdk/credential-provider-env/@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="],
"@aws-sdk/credential-provider-http/@aws-sdk/core": ["@aws-sdk/core@3.977.6", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw=="],
"@aws-sdk/credential-provider-http/@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="],
"@aws-sdk/credential-provider-ini/@aws-sdk/core": ["@aws-sdk/core@3.977.6", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw=="],
"@aws-sdk/credential-provider-ini/@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="],
"@aws-sdk/credential-provider-login/@aws-sdk/core": ["@aws-sdk/core@3.977.6", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw=="],
"@aws-sdk/credential-provider-login/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.41", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/signature-v4-multi-region": "^3.996.43", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ=="],
"@aws-sdk/credential-provider-login/@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="],
"@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.932.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-ozge/c7NdHUDyHqro6+P5oHt8wfKSUBN+olttiVfBe9Mw3wBMpPa3gQ0pZnG+gwBkKskBuip2bMR16tqYvUSEA=="],
"@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.932.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/types": "3.930.0", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/node-http-handler": "^4.4.5", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/util-stream": "^4.5.6", "tslib": "^2.6.2" } }, "sha512-b6N9Nnlg8JInQwzBkUq5spNaXssM3h3zLxGzpPrnw0nHSIWPJPTbZzA5Ca285fcDUFuKP+qf3qkuqlAjGOdWhg=="],
"@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.933.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/credential-provider-env": "3.932.0", "@aws-sdk/credential-provider-http": "3.932.0", "@aws-sdk/credential-provider-process": "3.932.0", "@aws-sdk/credential-provider-sso": "3.933.0", "@aws-sdk/credential-provider-web-identity": "3.933.0", "@aws-sdk/nested-clients": "3.933.0", "@aws-sdk/types": "3.930.0", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-HygGyKuMG5AaGXsmM0d81miWDon55xwalRHB3UmDg3QBhtunbNIoIaWUbNTKuBZXcIN6emeeEZw/YgSMqLc0YA=="],
"@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.932.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-BodZYKvT4p/Dkm28Ql/FhDdS1+p51bcZeMMu2TRtU8PoMDHnVDhHz27zASEKSZwmhvquxHrZHB0IGuVqjZUtSQ=="],
"@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.933.0", "", { "dependencies": { "@aws-sdk/client-sso": "3.933.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/token-providers": "3.933.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-/R1DBR7xNcuZIhS2RirU+P2o8E8/fOk+iLAhbqeSTq+g09fP/F6W7ouFpS5eVE2NIfWG7YBFoVddOhvuqpn51g=="],
"@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.933.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/nested-clients": "3.933.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-c7Eccw2lhFx2/+qJn3g+uIDWRuWi2A6Sz3PVvckFUEzPsP0dPUo19hlvtarwP5GzrsXn0yEPRVhpewsIaSCGaQ=="],
"@aws-sdk/credential-provider-process/@aws-sdk/core": ["@aws-sdk/core@3.977.6", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw=="],
"@aws-sdk/credential-provider-process/@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="],
"@aws-sdk/credential-provider-sso/@aws-sdk/core": ["@aws-sdk/core@3.977.6", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw=="],
"@aws-sdk/credential-provider-sso/@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="],
"@aws-sdk/credential-provider-web-identity/@aws-sdk/core": ["@aws-sdk/core@3.977.6", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw=="],
"@aws-sdk/credential-provider-web-identity/@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="],
"@aws-sdk/credential-providers/@aws-sdk/core": ["@aws-sdk/core@3.977.6", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw=="],
"@aws-sdk/credential-providers/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.78", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.67", "@aws-sdk/credential-provider-http": "^3.972.69", "@aws-sdk/credential-provider-ini": "^3.973.12", "@aws-sdk/credential-provider-process": "^3.972.67", "@aws-sdk/credential-provider-sso": "^3.973.11", "@aws-sdk/credential-provider-web-identity": "^3.972.73", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng=="],
"@aws-sdk/credential-providers/@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="],
"@aws-sdk/nested-clients/@aws-sdk/core": ["@aws-sdk/core@3.977.6", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw=="],
"@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.43", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA=="],
"@aws-sdk/nested-clients/@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="],
"@aws-sdk/token-providers/@aws-sdk/core": ["@aws-sdk/core@3.977.6", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw=="],
"@aws-sdk/token-providers/@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="],
"@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
"@azure/core-http/@azure/abort-controller": ["@azure/abort-controller@1.1.0", "", { "dependencies": { "tslib": "^2.2.0" } }, "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw=="],
@@ -6175,6 +6244,8 @@
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@effect/platform-node-shared/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
"@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
@@ -6229,10 +6300,6 @@
"@modelcontextprotocol/sdk/jose": ["jose@6.2.8", "", {}, "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ=="],
"@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
"@npmcli/git/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
"@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="],
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.13", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-v2269YxL9Yf+x3d+gRI63FP0vFQEiWgLyBzxe/Y+0yFDg2B/Tzf5dhh9VNfccVAQnfcfwQWyk/y6Bn7rUXXs7A=="],
@@ -6483,7 +6550,7 @@
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hlxLxqbdV+f7gBpL2SbLL+UhjV+DfUpJ28J4vSHekVV9fiTRrhqcd/ewJwaVbR/ahtLCGJPAVYzj+yJzaddc6g=="],
"ai-gateway-provider/@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-gateway-provider/@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=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -6721,8 +6788,6 @@
"mermaid/dompurify": ["dompurify@3.4.13", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ=="],
"mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
@@ -7103,22 +7168,14 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-sdk/client-cognito-identity/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="],
"@aws-sdk/client-cognito-identity/@aws-sdk/core/@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
"@aws-sdk/client-lambda/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="],
"@aws-sdk/client-lambda/@aws-sdk/core/@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.67", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.69", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.12", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/credential-provider-env": "^3.972.67", "@aws-sdk/credential-provider-http": "^3.972.69", "@aws-sdk/credential-provider-login": "^3.972.74", "@aws-sdk/credential-provider-process": "^3.972.67", "@aws-sdk/credential-provider-sso": "^3.973.11", "@aws-sdk/credential-provider-web-identity": "^3.972.73", "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.67", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.11", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/token-providers": "3.1103.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.73", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A=="],
"@aws-sdk/client-sts/@aws-sdk/core/fast-xml-parser": ["fast-xml-parser@4.4.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw=="],
"@aws-sdk/client-sts/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.775.0", "", { "dependencies": { "@aws-sdk/core": "3.775.0", "@aws-sdk/types": "3.775.0", "@smithy/property-provider": "^4.0.2", "@smithy/types": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6ESVxwCbGm7WZ17kY1fjmxQud43vzJFoLd4bmlR+idQSWdqlzGDYdcfzpjDKTcivdtNrVYmFvcH1JBUwCRAZhw=="],
@@ -7133,11 +7190,51 @@
"@aws-sdk/client-sts/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.782.0", "", { "dependencies": { "@aws-sdk/core": "3.775.0", "@aws-sdk/nested-clients": "3.782.0", "@aws-sdk/types": "3.775.0", "@smithy/property-provider": "^4.0.2", "@smithy/types": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-xCna0opVPaueEbJoclj5C6OpDNi0Gynj+4d7tnuXGgQhTHPyAz8ZyClkVqpi5qvHTgxROdUEDxWqEO5jqRHZHQ=="],
"@aws-sdk/credential-provider-env/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="],
"@aws-sdk/credential-provider-env/@aws-sdk/core/@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
"@aws-sdk/credential-provider-http/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="],
"@aws-sdk/credential-provider-http/@aws-sdk/core/@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
"@aws-sdk/credential-provider-ini/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="],
"@aws-sdk/credential-provider-ini/@aws-sdk/core/@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
"@aws-sdk/credential-provider-login/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="],
"@aws-sdk/credential-provider-login/@aws-sdk/core/@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
"@aws-sdk/credential-provider-login/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.43", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA=="],
"@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-o1GX0+IPlFi/D8ei9y/jj3yucJWNfPnbB5appVBWevAyUdZA5KzQ2nK/hDxiu9olTZlFEFpf1m1Rn3FaGxHqsw=="],
"@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.933.0", "", { "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/nested-clients": "3.933.0", "@aws-sdk/types": "3.930.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Qzq7zj9yXUgAAJEbbmqRhm0jmUndl8nHG0AbxFEfCfQRVZWL96Qzx0mf8lYwT9hIMrXncLwy31HOthmbXwFRwQ=="],
"@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-o1GX0+IPlFi/D8ei9y/jj3yucJWNfPnbB5appVBWevAyUdZA5KzQ2nK/hDxiu9olTZlFEFpf1m1Rn3FaGxHqsw=="],
"@aws-sdk/credential-provider-process/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="],
"@aws-sdk/credential-provider-process/@aws-sdk/core/@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
"@aws-sdk/credential-provider-sso/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="],
"@aws-sdk/credential-provider-sso/@aws-sdk/core/@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
"@aws-sdk/credential-provider-web-identity/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="],
"@aws-sdk/credential-provider-web-identity/@aws-sdk/core/@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
"@aws-sdk/credential-providers/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="],
"@aws-sdk/credential-providers/@aws-sdk/core/@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
"@aws-sdk/nested-clients/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="],
"@aws-sdk/nested-clients/@aws-sdk/core/@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
"@aws-sdk/token-providers/@aws-sdk/core/@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.37", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg=="],
"@aws-sdk/token-providers/@aws-sdk/core/@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
@@ -7485,9 +7582,9 @@
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"ai-gateway-provider/@ai-sdk/anthropic/@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/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"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/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=="],
"ai/@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
@@ -7713,8 +7810,6 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"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=="],
@@ -8201,14 +8296,6 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.41", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/signature-v4-multi-region": "^3.996.43", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.41", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/signature-v4-multi-region": "^3.996.43", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1103.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/nested-clients": "^3.997.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.41", "", { "dependencies": { "@aws-sdk/core": "^3.977.6", "@aws-sdk/signature-v4-multi-region": "^3.996.43", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ=="],
"@aws-sdk/client-sts/@aws-sdk/core/fast-xml-parser/strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="],
"@aws-sdk/client-sts/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.782.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.775.0", "@aws-sdk/middleware-host-header": "3.775.0", "@aws-sdk/middleware-logger": "3.775.0", "@aws-sdk/middleware-recursion-detection": "3.775.0", "@aws-sdk/middleware-user-agent": "3.782.0", "@aws-sdk/region-config-resolver": "3.775.0", "@aws-sdk/types": "3.775.0", "@aws-sdk/util-endpoints": "3.782.0", "@aws-sdk/util-user-agent-browser": "3.775.0", "@aws-sdk/util-user-agent-node": "3.782.0", "@smithy/config-resolver": "^4.1.0", "@smithy/core": "^3.2.0", "@smithy/fetch-http-handler": "^5.0.2", "@smithy/hash-node": "^4.0.2", "@smithy/invalid-dependency": "^4.0.2", "@smithy/middleware-content-length": "^4.0.2", "@smithy/middleware-endpoint": "^4.1.0", "@smithy/middleware-retry": "^4.1.0", "@smithy/middleware-serde": "^4.0.3", "@smithy/middleware-stack": "^4.0.2", "@smithy/node-config-provider": "^4.0.2", "@smithy/node-http-handler": "^4.0.4", "@smithy/protocol-http": "^5.1.0", "@smithy/smithy-client": "^4.2.0", "@smithy/types": "^4.2.0", "@smithy/url-parser": "^4.0.2", "@smithy/util-base64": "^4.0.0", "@smithy/util-body-length-browser": "^4.0.0", "@smithy/util-body-length-node": "^4.0.0", "@smithy/util-defaults-mode-browser": "^4.0.8", "@smithy/util-defaults-mode-node": "^4.0.8", "@smithy/util-endpoints": "^3.0.2", "@smithy/util-middleware": "^4.0.2", "@smithy/util-retry": "^4.0.2", "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-QOYC8q7luzHFXrP0xYAqBctoPkynjfV0r9dqntFu4/IWMTyC1vlo1UTxFAjIPyclYw92XJyEkVCVg9v/nQnsUA=="],
@@ -8219,6 +8306,8 @@
"@aws-sdk/client-sts/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.782.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.775.0", "@aws-sdk/middleware-host-header": "3.775.0", "@aws-sdk/middleware-logger": "3.775.0", "@aws-sdk/middleware-recursion-detection": "3.775.0", "@aws-sdk/middleware-user-agent": "3.782.0", "@aws-sdk/region-config-resolver": "3.775.0", "@aws-sdk/types": "3.775.0", "@aws-sdk/util-endpoints": "3.782.0", "@aws-sdk/util-user-agent-browser": "3.775.0", "@aws-sdk/util-user-agent-node": "3.782.0", "@smithy/config-resolver": "^4.1.0", "@smithy/core": "^3.2.0", "@smithy/fetch-http-handler": "^5.0.2", "@smithy/hash-node": "^4.0.2", "@smithy/invalid-dependency": "^4.0.2", "@smithy/middleware-content-length": "^4.0.2", "@smithy/middleware-endpoint": "^4.1.0", "@smithy/middleware-retry": "^4.1.0", "@smithy/middleware-serde": "^4.0.3", "@smithy/middleware-stack": "^4.0.2", "@smithy/node-config-provider": "^4.0.2", "@smithy/node-http-handler": "^4.0.4", "@smithy/protocol-http": "^5.1.0", "@smithy/smithy-client": "^4.2.0", "@smithy/types": "^4.2.0", "@smithy/url-parser": "^4.0.2", "@smithy/util-base64": "^4.0.0", "@smithy/util-body-length-browser": "^4.0.0", "@smithy/util-body-length-node": "^4.0.0", "@smithy/util-defaults-mode-browser": "^4.0.8", "@smithy/util-defaults-mode-node": "^4.0.8", "@smithy/util-endpoints": "^3.0.2", "@smithy/util-middleware": "^4.0.2", "@smithy/util-retry": "^4.0.2", "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-QOYC8q7luzHFXrP0xYAqBctoPkynjfV0r9dqntFu4/IWMTyC1vlo1UTxFAjIPyclYw92XJyEkVCVg9v/nQnsUA=="],
"@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-o1GX0+IPlFi/D8ei9y/jj3yucJWNfPnbB5appVBWevAyUdZA5KzQ2nK/hDxiu9olTZlFEFpf1m1Rn3FaGxHqsw=="],
"@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
@@ -8881,12 +8970,6 @@
"@astrojs/vercel/astro/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-ini/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.43", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.43", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-web-identity/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.43", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA=="],
"@aws-sdk/client-sts/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.782.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.775.0", "@aws-sdk/middleware-host-header": "3.775.0", "@aws-sdk/middleware-logger": "3.775.0", "@aws-sdk/middleware-recursion-detection": "3.775.0", "@aws-sdk/middleware-user-agent": "3.782.0", "@aws-sdk/region-config-resolver": "3.775.0", "@aws-sdk/types": "3.775.0", "@aws-sdk/util-endpoints": "3.782.0", "@aws-sdk/util-user-agent-browser": "3.775.0", "@aws-sdk/util-user-agent-node": "3.782.0", "@smithy/config-resolver": "^4.1.0", "@smithy/core": "^3.2.0", "@smithy/fetch-http-handler": "^5.0.2", "@smithy/hash-node": "^4.0.2", "@smithy/invalid-dependency": "^4.0.2", "@smithy/middleware-content-length": "^4.0.2", "@smithy/middleware-endpoint": "^4.1.0", "@smithy/middleware-retry": "^4.1.0", "@smithy/middleware-serde": "^4.0.3", "@smithy/middleware-stack": "^4.0.2", "@smithy/node-config-provider": "^4.0.2", "@smithy/node-http-handler": "^4.0.4", "@smithy/protocol-http": "^5.1.0", "@smithy/smithy-client": "^4.2.0", "@smithy/types": "^4.2.0", "@smithy/url-parser": "^4.0.2", "@smithy/util-base64": "^4.0.0", "@smithy/util-body-length-browser": "^4.0.0", "@smithy/util-body-length-node": "^4.0.0", "@smithy/util-defaults-mode-browser": "^4.0.8", "@smithy/util-defaults-mode-node": "^4.0.8", "@smithy/util-endpoints": "^3.0.2", "@smithy/util-middleware": "^4.0.2", "@smithy/util-retry": "^4.0.2", "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-QOYC8q7luzHFXrP0xYAqBctoPkynjfV0r9dqntFu4/IWMTyC1vlo1UTxFAjIPyclYw92XJyEkVCVg9v/nQnsUA=="],
"@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
+7 -5
View File
@@ -37,9 +37,10 @@
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-beta.101",
"@effect/platform-node": "4.0.0-beta.101",
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
"@effect/opentelemetry": "4.0.0-beta.107",
"@effect/platform-node": "4.0.0-beta.107",
"@effect/platform-node-shared": "4.0.0-beta.107",
"@effect/sql-sqlite-bun": "4.0.0-beta.107",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
@@ -70,7 +71,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.101",
"effect": "4.0.0-beta.107",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -154,6 +155,7 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@effect/platform-node-shared": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:"
@@ -164,6 +166,7 @@
"@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",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
@@ -171,7 +174,6 @@
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
}
+3 -2
View File
@@ -202,14 +202,15 @@ export const errorText = (error: unknown) => {
* decoder, and drops empty / `[DONE]` keep-alive events so the downstream
* `decodeChunk` sees one JSON string per element. The SSE channel emits a
* `Retry` control event on its error channel; we drop it here (we don't
* implement client-driven retries) so the public error channel stays
* `AIError`.
* implement client-driven retries). Decoder failures become provider output
* errors so the public error channel stays `AIError`.
*/
export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.Stream<string, AIError> =>
bytes.pipe(
Stream.decodeText(),
Stream.pipeThroughChannel(Sse.decode()),
Stream.catchTag("Retry", () => Stream.empty),
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"),
Stream.map((event) => event.data),
)
+1 -1
View File
@@ -57,7 +57,7 @@ export const isContextOverflowFailure = (failure: unknown) =>
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
const SERVER_CODES = new Set([
"api_error",
+1 -1
View File
@@ -148,7 +148,7 @@ export const AIErrorReason = Schema.Union([
]).pipe(Schema.toTaggedUnion("_tag"))
export type AIErrorReason = Schema.Schema.Type<typeof AIErrorReason>
export class AIError extends Schema.TaggedErrorClass<AIError>()("AI.Error", {
export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
module: Schema.String,
method: Schema.String,
reason: AIErrorReason,
@@ -75,7 +75,6 @@ describe("Amazon Bedrock Mantle provider", () => {
}),
),
),
Effect.flip,
)
expect(seen).toEqual([{ url: "https://mantle.test/v1/chat/completions", authorization: "Bearer test-key" }])
+74
View File
@@ -702,6 +702,80 @@ describe("Gemini route", () => {
}),
)
it.effect("leaves unsigned parallel calls unchanged after a signed Gemini 3 call", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: gemini3,
messages: [
Message.assistant([
ToolCallPart.make({
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { thoughtSignature: "parallel_signature" } },
}),
ToolCallPart.make({ id: "tool_1", name: "lookup", input: { query: "news" } }),
ToolCallPart.make({ id: "tool_2", name: "lookup", input: { query: "sports" } }),
]),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
thoughtSignature: "parallel_signature",
},
{
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
thoughtSignature: undefined,
},
{
functionCall: { id: undefined, name: "lookup", args: { query: "sports" } },
thoughtSignature: undefined,
},
],
},
])
}),
)
it.effect("adds the validator bypass sentinel to every call in an unsigned Gemini 3 batch", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: gemini3,
messages: [
Message.assistant([
ToolCallPart.make({ id: "tool_0", name: "lookup", input: { query: "weather" } }),
ToolCallPart.make({ id: "tool_1", name: "lookup", input: { query: "news" } }),
]),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
thoughtSignature: "skip_thought_signature_validator",
},
{
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
thoughtSignature: "skip_thought_signature_validator",
},
],
},
])
}),
)
it.effect("emits streamed tool calls and maps finish reason", () =>
Effect.gen(function* () {
const body = sseEvents({
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Effect, Schema, Stream } from "effect"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import * as OpenAIResponses from "../src/protocols/openai-responses.js"
import {
@@ -90,6 +90,18 @@ describe("AI.Usage", () => {
expect(ProviderShared.sumTokens()).toBeUndefined()
})
test("sseFraming maps decoder failures to AI errors", async () => {
const error = await Effect.runPromise(
ProviderShared.sseFraming(Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`))).pipe(
Stream.runCollect,
Effect.flip,
),
)
expect(error).toBeInstanceOf(AIError)
expect(error.reason._tag).toBe("InvalidProviderOutput")
})
test("visibleOutputTokens clamps reasoning > output to zero", () => {
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
@@ -9,6 +9,7 @@ import {
import {
assistantMessage,
partUpdated,
renderedPartID,
setupTimeline,
shell,
textPart,
@@ -97,13 +98,15 @@ test.describe("timeline adverse visual stability", () => {
element.scrollTop = 0
})
await page.waitForTimeout(300)
const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`)
const trigger = page.locator(
`[data-timeline-part-id="${renderedPartID(targetID)}"] [data-slot="collapsible-trigger"]`,
)
await expect(trigger).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(targetID)}"]`)).toHaveCount(0)
await scroller.evaluate((element) => (element.scrollTop = 0))
await expect(trigger).toBeVisible()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
@@ -127,13 +130,16 @@ test.describe("timeline adverse visual stability", () => {
cpuRate: 4,
})
await waitForVisualSettle(page, [
`[data-timeline-part-id="${shellID}"]`,
`[data-timeline-part-id="${followingID}"]`,
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
shell: {
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
@@ -184,10 +190,13 @@ test.describe("timeline adverse visual stability", () => {
})
const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
shell: {
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
@@ -1,192 +0,0 @@
import { expect, test } from "@playwright/test"
import {
defineVisualRegions,
reportVisualStability,
startVisualProbe,
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import {
assistantID,
assistantMessage,
event,
partUpdated,
setupTimeline,
textPart,
toolPart,
userMessage,
waitForVisualSettle,
} from "./fixture"
const inputs = {
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
glob: { path: ".", pattern: "**/*.ts" },
grep: { path: ".", pattern: "stable", include: "*.ts" },
list: { path: "src" },
}
test("appends context operations while the group is expanded", async ({ page }, testInfo) => {
const firstID = "prt_append_01_read"
const followingID = "prt_append_99_following"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([toolPart(firstID, "read", "running", inputs.read), textPart(followingID, "Following append")], {
completed: false,
}),
],
cpuRate: 4,
})
const initialGroup = `[data-timeline-part-ids="${firstID}"]`
await page.locator(`${initialGroup} [data-slot="collapsible-trigger"]`).click()
await waitForVisualSettle(page, [initialGroup, `[data-timeline-part-id="${followingID}"]`])
const regions = defineVisualRegions({
context: {
selector: '[data-timeline-part-ids^="prt_append_01_read"]',
closest: '[data-timeline-row="AssistantPart"]',
},
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(toolPart("prt_append_02_glob", "glob", "running", inputs.glob)), 180)
await timeline.send(partUpdated(toolPart("prt_append_03_grep", "grep", "completed", inputs.grep)), 240)
await timeline.send(partUpdated(toolPart("prt_append_04_list", "list", "completed", inputs.list)), 500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"context-append",
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["context", "following"] },
{ type: "unique", regions: ["context", "following"] },
{ type: "stable", regions: ["context", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
{ type: "preserve-bottom-anchor" },
{ type: "flow", regions: ["context", "following"] },
],
{ perMarker: true },
),
)
await expect(
page.locator(
'[data-timeline-part-ids="prt_append_01_read,prt_append_02_glob,prt_append_03_grep,prt_append_04_list"]',
),
).toBeVisible()
await expect(
page.locator('[data-timeline-part-ids^="prt_append_01_read"] [data-slot="collapsible-trigger"]'),
).toHaveAttribute("aria-expanded", "true")
})
test("splits and merges context groups when a middle text part changes", async ({ page }, testInfo) => {
const textID = "prt_split_02_text"
const followingID = "prt_split_99_following"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart("prt_split_01_read", "read", "completed", inputs.read),
textPart(textID, "Boundary"),
toolPart("prt_split_03_glob", "glob", "completed", inputs.glob),
textPart(followingID, "Following split groups"),
]),
],
cpuRate: 4,
})
const regions = defineVisualRegions({
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: textID }),
500,
)
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read,prt_split_03_glob"]')).toBeVisible()
await timeline.send(partUpdated(textPart(textID, "Boundary restored")), 500)
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read"]')).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_split_03_glob"]')).toBeVisible()
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"context-split-merge",
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["following"] },
{ type: "unique", regions: ["following"] },
{ type: "stable", regions: ["following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 1 },
{ type: "label-stability", regions: "all" },
],
{ perMarker: true },
),
)
})
test("removing the first context member replaces the group once without overlapping following content", async ({
page,
}, testInfo) => {
const ids = ["prt_key_01_read", "prt_key_02_glob", "prt_key_03_grep"]
const followingID = "prt_key_99_following"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(ids[0]!, "read", "completed", inputs.read),
toolPart(ids[1]!, "glob", "completed", inputs.glob),
toolPart(ids[2]!, "grep", "completed", inputs.grep),
textPart(followingID, "Following replaced group"),
]),
],
cpuRate: 4,
})
const original = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
const originalRowKey = await original.evaluate((element) =>
element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key"),
)
await original.locator('[data-slot="collapsible-trigger"]').click()
await expect(original.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
const regions = defineVisualRegions({
context: {
selector: '[data-timeline-part-ids*="prt_key_02_glob"]',
closest: '[data-timeline-row="AssistantPart"]',
},
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: ids[0] }),
500,
)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"context-first-remove",
trace,
visualPlan(regions, [
{ type: "required", regions: ["context", "following"] },
{ type: "unique", regions: ["context", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
{ type: "flow", regions: ["context", "following"] },
]),
)
await expect(page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)).toBeVisible()
expect(
await page
.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)
.evaluate((element) => element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key")),
).toBe(originalRowKey)
await expect(
page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"] [data-slot="collapsible-trigger"]`),
).toHaveAttribute("aria-expanded", "true")
})
@@ -9,6 +9,7 @@ import {
import {
assistantMessage,
partUpdated,
renderedPartID,
setupTimeline,
shell,
textPart,
@@ -34,13 +35,16 @@ for (const deviceScaleFactor of [1, 1.25]) {
seedHistory: true,
})
await waitForVisualSettle(page, [
`[data-timeline-part-id="${shellID}"]`,
`[data-timeline-part-id="${followingID}"]`,
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
shell: {
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
@@ -71,13 +75,16 @@ for (const reducedMotion of [true]) {
seedHistory: true,
})
await waitForVisualSettle(page, [
`[data-timeline-part-id="${shellID}"]`,
`[data-timeline-part-id="${followingID}"]`,
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
shell: {
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
@@ -9,6 +9,7 @@ import {
import {
assistantMessage,
partUpdated,
renderedPartID,
setupTimeline,
textPart,
toolPart,
@@ -43,11 +44,17 @@ for (const profile of profiles) {
settings: { editToolPartsExpanded: true },
cpuRate: 4,
})
await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"]`, `[data-timeline-part-id="${followingID}"]`])
await waitForVisualSettle(page, [
`[data-timeline-part-id="${renderedPartID(partID)}"]`,
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
])
const regions = defineVisualRegions({
tool: { selector: `[data-timeline-part-id="${partID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
tool: {
selector: `[data-timeline-part-id="${renderedPartID(partID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
@@ -9,6 +9,7 @@ import {
import {
assistantMessage,
partUpdated,
renderedPartID,
setupTimeline,
textPart,
toolPart,
@@ -35,12 +36,23 @@ test("adds patch files incrementally without resetting outer expansion", async (
cpuRate: 4,
seedHistory: true,
})
const trigger = page.locator(`[data-timeline-part-id="${patchID}"] [data-slot="collapsible-trigger"]`).first()
const trigger = page
.locator(`[data-timeline-part-id="${renderedPartID(patchID)}"] [data-slot="collapsible-trigger"]`)
.first()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await waitForVisualSettle(page, [`[data-timeline-part-id="${patchID}"]`, `[data-timeline-part-id="${followingID}"]`])
await waitForVisualSettle(page, [
`[data-timeline-part-id="${renderedPartID(patchID)}"]`,
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
])
const regions = defineVisualRegions({
patch: { selector: `[data-timeline-part-id="${patchID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
patch: {
selector: `[data-timeline-part-id="${renderedPartID(patchID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
following: {
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
const second = patchFile("src/b.ts", "add")
@@ -17,19 +17,28 @@ describe("timeline fixture validation", () => {
test("rejects malformed SDK values at runtime", () => {
expect(() =>
assistantMessage([], {
error: { name: "APIError", data: { message: "failed" } } as never,
error: { type: "APIError", message: 1 } as never,
}),
).toThrow()
expect(() =>
validateTimelineEvent({
directory: "C:/OpenCode/TimelineStability",
payload: {
id: "evt_invalid_status",
type: "session.status",
properties: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } },
},
id: "evt_invalid_status",
created: 1,
type: "session.status",
data: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } },
}),
).toThrow()
expect(() => validateTimelineMessages([{ ...userMessage(), id: "invalid" } as never])).toThrow()
expect(() => validateTimelineMessages([{ ...userMessage(), time: { created: "invalid" } } as never])).toThrow()
expect(() =>
validateTimelineMessages([
userMessage(),
{
...assistantMessage(),
content: [{ type: "tool", id: "call_invalid", name: "bash", state: { status: "completed" } }],
} as never,
]),
).toThrow()
})
test("rejects duplicate IDs and orphan assistants", () => {
@@ -42,8 +51,8 @@ describe("timeline fixture validation", () => {
test("assigns deterministic event IDs", () => {
const first = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "busy" } })
const second = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "idle" } })
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
expect(first.id).toMatch(/^evt_timeline_\d{4}$/)
expect(Number(second.id.slice(-4))).toBe(Number(first.id.slice(-4)) + 1)
})
})
@@ -1,9 +1,16 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Event } from "@opencode-ai/schema/event"
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import type { SessionInfo, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
import type {
JsonValue,
OpenCodeEvent,
SessionInfo,
SessionMessageAssistant,
SessionMessageInfo,
SessionMessageUser,
SessionStatus,
SessionStructuredError,
} from "@opencode-ai/client/promise"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { expect, type Page } from "@playwright/test"
import { Schema } from "effect"
import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -18,50 +25,80 @@ export const assistantID = "msg_1001_timeline_assistant"
export const title = "Timeline visual stability"
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const tokens = { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }
type Session = SessionInfo
type GlobalEvent = {
directory: string
project?: string
workspace?: string
payload: {
id: string
type: string
properties: Record<string, unknown>
}
type TextSeed = {
id: string
type: "text"
text: string
messageID?: string
}
type FileSeed = {
id: string
type: "file"
mime: string
filename?: string
url: string
source?: { type: string; path?: string; text?: { value: string; start: number; end: number } }
}
type AgentSeed = {
id: string
type: "agent"
name: string
source?: { value: string; start: number; end: number }
}
type ReasoningSeed = {
id: string
type: "reasoning"
text: string
time?: { start: number; end?: number }
metadata?: Record<string, unknown>
messageID?: string
}
type ToolSeed = {
id: string
type: "tool"
callID: string
tool: string
messageID?: string
executed?: boolean
providerState?: Record<string, unknown>
providerResultState?: Record<string, unknown>
state:
| { status: "pending"; input: Record<string, unknown>; raw: string }
| {
status: "running"
input: Record<string, unknown>
title?: string
metadata: Record<string, unknown>
time: { start: number }
}
| {
status: "completed"
input: Record<string, unknown>
output: string
title: string
metadata: Record<string, unknown>
time: { start: number; end: number }
}
| {
status: "error"
input: Record<string, unknown>
error: string
metadata: Record<string, unknown>
time: { start: number; end: number }
}
}
type TimelineProperties = {
"message.updated": { sessionID: string; info: Message }
"message.removed": { sessionID: string; messageID: string }
"message.part.updated": { sessionID: string; part: Part; time: number }
"message.part.removed": { sessionID: string; messageID: string; partID: string }
"message.part.delta": { sessionID: string; messageID: string; partID: string; field: string; delta: string }
"session.status": { sessionID: string; status: SessionStatus }
}
type TimelinePayload = {
[Type in keyof TimelineProperties]: { id: string; type: Type; properties: TimelineProperties[Type] }
}[keyof TimelineProperties]
type DeepReadonly<Value> = Value extends readonly unknown[]
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
: Value extends object
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
: Value
export type TimelineEvent = DeepReadonly<Omit<GlobalEvent, "payload"> & { payload: TimelinePayload }>
export type EventPayload = TimelineEvent
export type ToolStatus = ToolState["status"]
export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] }
type UserPart = Extract<Part, { type: "text" | "file" | "agent" | "subtask" }>
type AssistantPart = Exclude<Part, { type: "agent" | "subtask" }>
type OwnedPart<Owner extends Message["role"]> = Owner extends "user" ? UserPart : AssistantPart
export type PartSeed<Owner extends Message["role"]> =
OwnedPart<Owner> extends infer Candidate
? Candidate extends Part
? Omit<Candidate, "sessionID" | "messageID">
: never
: never
export type TimelineMessage = SessionMessageUser | SessionMessageAssistant
export type TimelineEvent = OpenCodeEvent | readonly OpenCodeEvent[]
export type EventPayload = OpenCodeEvent
export type ToolStatus = ToolSeed["state"]["status"]
export type PartSeed<Owner extends "user" | "assistant"> = Owner extends "user"
? TextSeed | FileSeed | AgentSeed
: TextSeed | ReasoningSeed | ToolSeed
type ToolOptions<State extends ToolStatus> = State extends "pending"
? { output?: never; title?: never; metadata?: never; error?: never }
@@ -71,26 +108,19 @@ type ToolOptions<State extends ToolStatus> = State extends "pending"
? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
: { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
const decodeOptions = { errors: "all", onExcessProperty: "error" } as const
const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts)
const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info)
const timelineEventSchema = Schema.Union([
eventSchema("message.updated", SessionV1.Event.MessageUpdated.data),
eventSchema("message.removed", SessionV1.Event.MessageRemoved.data),
eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data),
eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data),
eventSchema("message.part.delta", SessionV1.Event.PartDelta.data),
eventSchema("session.status", SessionStatusEvent.Status.data),
])
const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema)
type PartRef = { messageID: string; type: "text" | "reasoning" | "tool"; ordinal?: number }
const partRefs = new Map<string, PartRef>()
const nextOrdinals = new Map<string, { text: number; reasoning: number }>()
const startedParts = new Set<string>()
const toolStates = new Map<string, ToolStatus>()
let eventSequence = 0
let durableSequence = -1
export async function setupTimeline(
page: Page,
input: {
messages?: TimelineMessage[]
currentMessages?: SessionMessageInfo[]
sessionMessages?: SessionMessageInfo[]
sessionStatus?: Record<string, SessionStatus>
settings?: Record<string, boolean>
sessions?: Session[]
@@ -103,40 +133,26 @@ export async function setupTimeline(
seedHistory?: boolean
} = {},
) {
eventSequence = 0
durableSequence = -1
const sessions = input.sessions ?? [session()]
const messages =
input.currentMessages ??
input.sessionMessages ??
validateTimelineMessages([
...(input.seedHistory ? historyMessages(18) : []),
...(input.messages ?? [userMessage(), assistantMessage()]),
])
const active = messages.findLast((message) =>
"info" in message ? message.info.role === "assistant" : message.type === "assistant",
)
const initialStatus = decodeStatus(
active &&
("info" in active
? active.info.role === "assistant" && active.info.time.completed === undefined
: active.type === "assistant" && active.time.completed === undefined)
? { type: "busy" }
: { type: "idle" },
decodeOptions,
)
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const transport = await installSseTransport<EventPayload>(page, {
server,
retry: input.eventRetry ?? 20,
})
const active = messages.findLast((message) => message.type === "assistant")
const initialStatus: SessionStatus =
active?.type === "assistant" && active.time.completed === undefined ? { type: "busy" } : { type: "idle" }
const transport = await installSseTransport(page, { server, retry: input.eventRetry ?? 20 })
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: project(),
provider: provider(),
sessions,
sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus },
pageMessages: () => ({
items: messages,
}),
pageMessages: () => ({ items: messages }),
})
await page.addInitScript((settings) => {
localStorage.setItem(
@@ -169,7 +185,7 @@ export async function setupTimeline(
mobile: false,
})
}
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionReady(page, { server, sessionID, title })
await transport.waitForConnection()
if (input.cpuRate && input.cpuRate > 1) {
@@ -179,15 +195,25 @@ export async function setupTimeline(
return {
transport,
async send(event: TimelineEvent, delay = 0) {
const valid = validateTimelineEvent(event)
await transport.send(valid, { marker: describeEvent(valid) })
async send(input: TimelineEvent, delay = 0) {
const events = timelineEvents(input)
if (events.length === 1) await transport.send(events[0]!, { marker: describeEvent(events[0]!) })
if (events.length > 1)
await transport.burst(
events,
events.map((item) => ({ marker: describeEvent(item) })),
)
if (delay) await page.waitForTimeout(delay)
},
async sendAll(sequence: { event: TimelineEvent; delay: number }[]) {
for (const item of sequence) {
const valid = validateTimelineEvent(item.event)
await transport.send(valid, { marker: describeEvent(valid) })
const events = timelineEvents(item.event)
if (events.length === 1) await transport.send(events[0]!, { marker: describeEvent(events[0]!) })
if (events.length > 1)
await transport.burst(
events,
events.map((event) => ({ marker: describeEvent(event) })),
)
await page.waitForTimeout(item.delay)
}
},
@@ -207,72 +233,63 @@ export async function setupTimeline(
)
},
async waitForPart(partID: string) {
const part = page.locator(`[data-timeline-part-id="${partID}"]`)
const part = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`)
await expect(part).toHaveCount(1)
await expect(part).toBeVisible()
},
}
}
function describeEvent(event: EventPayload) {
if (event.payload.type === "message.part.updated") {
const part = event.payload.properties.part
return [
event.payload.type,
part.id,
part.type === "tool" ? part.tool : part.type,
part.type === "tool" ? part.state.status : undefined,
]
.filter(Boolean)
.join(":")
}
if (event.payload.type === "session.status") {
const status = event.payload.properties.status
return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined]
.filter((value) => value !== undefined)
.join(":")
}
return event.payload.type
function timelineEvents(input: TimelineEvent) {
return (Array.isArray(input) ? input : [input]).map(validateTimelineEvent)
}
export function event<const Type extends TimelinePayload["type"]>(
type: Type,
properties: Extract<TimelinePayload, { type: Type }>["properties"],
): TimelineEvent
export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent {
return validateTimelineEvent({
directory,
payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties },
})
function describeEvent(event: OpenCodeEvent) {
if (event.type.startsWith("session.tool.")) {
const data = event.data as { id?: string }
return [event.type, data.id].filter(Boolean).join(":")
}
return event.type
}
export function validateTimelineEvent(input: unknown): TimelineEvent {
return decodeEvent(input, decodeOptions) as TimelineEvent
export function event(
type: "session.status",
data: Extract<OpenCodeEvent, { type: "session.status" }>["data"],
): OpenCodeEvent {
return makeEvent(type, data)
}
export function validateTimelineEvent(input: unknown): OpenCodeEvent {
if (!input || typeof input !== "object") throw new Error("Timeline event must be an object")
if (!("type" in input) || typeof input.type !== "string") throw new Error("Timeline event requires a type")
const definition = EventManifest.ServerDefinitions.find((definition) => definition.type === input.type)
if (!definition) throw new Error(`Unknown timeline event: ${input.type}`)
return Schema.decodeUnknownSync(definition)(input) as OpenCodeEvent
}
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
input.forEach((message) => decodeMessage(message, decodeOptions))
const messages = [...input]
const messages = input.map((message): TimelineMessage => {
const decoded = Schema.decodeUnknownSync(SessionMessage.Info)(message)
if (decoded.type !== "user" && decoded.type !== "assistant")
throw new Error(`Unsupported timeline message type: ${decoded.type}`)
return message
})
const messageIDs = new Set<string>()
const partIDs = new Set<string>()
const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id))
let parentID: string | undefined
messages.forEach((message) => {
if (messageIDs.has(message.info.id))
throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`)
messageIDs.add(message.info.id)
if (message.info.role === "assistant" && !users.has(message.info.parentID))
throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`)
message.parts.forEach((part) => {
if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id)
throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`)
if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type))
throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`)
if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type))
throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`)
if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
partIDs.add(part.id)
})
if (messageIDs.has(message.id)) throw new Error(`Timeline fixture has duplicate message ID: ${message.id}`)
messageIDs.add(message.id)
if (message.type === "user") parentID = message.id
if (message.type === "assistant") {
const expected = typeof message.metadata?.parentID === "string" ? message.metadata.parentID : parentID
if (!expected || expected !== parentID)
throw new Error(`Timeline assistant ${message.id} must reference a parent user in the fixture`)
message.content.forEach((part) => {
if (part.type !== "tool") return
if (partRefs.has(part.id) && partRefs.get(part.id)?.messageID !== message.id)
throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
})
}
})
return messages
}
@@ -334,55 +351,137 @@ export function historyMessages(count: number): TimelineMessage[] {
}).flat()
}
export function partUpdated(part: Part | PartSeed<"assistant">) {
const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID }
decodePart(owned, decodeOptions)
return event("message.part.updated", {
sessionID,
part: owned,
time: 1700000002000,
})
export function partUpdated(part: PartSeed<"assistant">): readonly OpenCodeEvent[] {
const messageID = part.messageID ?? assistantID
const started = startedParts.has(part.id)
const ref = partRef(part.id, messageID, part.type)
if (part.type === "text") {
startedParts.add(part.id)
return [
...(started
? []
: [makeEvent("session.text.started", { sessionID, assistantMessageID: messageID, ordinal: ref.ordinal! })]),
makeEvent("session.text.ended", {
sessionID,
assistantMessageID: messageID,
ordinal: ref.ordinal!,
text: part.text,
}),
]
}
if (part.type === "reasoning") {
startedParts.add(part.id)
return [
...(started
? []
: [
makeEvent("session.reasoning.started", {
sessionID,
assistantMessageID: messageID,
ordinal: ref.ordinal!,
state: jsonRecord(part.metadata),
}),
]),
makeEvent("session.reasoning.ended", {
sessionID,
assistantMessageID: messageID,
ordinal: ref.ordinal!,
text: part.text,
state: jsonRecord(part.metadata),
}),
]
}
return toolEvents(part, messageID)
}
export function renderedPartID(partID: string) {
const ref = partRefs.get(partID)
if (!ref || ref.type === "tool") return partID
return `${ref.messageID}:${ref.type}:${ref.ordinal}`
}
export function partDelta(partID: string, delta: string, messageID = assistantID) {
return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta })
const ref = partRefs.get(partID)
if (!ref || ref.type !== "text" || ref.ordinal === undefined) throw new Error(`Unknown text part: ${partID}`)
return makeEvent("session.text.delta", {
sessionID,
assistantMessageID: messageID,
ordinal: ref.ordinal,
delta,
})
}
export function messageUpdated(info: Message) {
return event("message.updated", { sessionID, info })
export function messageUpdated(info: SessionMessageAssistant) {
if (info.error)
return makeEvent("session.step.failed", {
sessionID,
assistantMessageID: info.id,
error: info.error,
cost: info.cost,
tokens: info.tokens,
})
return makeEvent("session.step.ended", {
sessionID,
assistantMessageID: info.id,
finish: info.finish ?? "stop",
cost: info.cost ?? 0,
tokens: info.tokens ?? tokens,
})
}
export function status(type: SessionStatus["type"], attempt = 1) {
return event("session.status", {
if (type === "busy") return makeEvent("session.execution.started", { sessionID })
if (type === "idle") return makeEvent("session.execution.succeeded", { sessionID })
return makeEvent("session.retry.scheduled", {
sessionID,
status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type },
assistantMessageID: assistantID,
attempt,
at: 1700000010000,
error: { type: "provider.error", message: "Rate limited" },
})
}
export function userMessage(
parts?: PartSeed<"user">[],
input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {},
): Extract<TimelineMessage, { info: { role: "user" } }> {
input: { id?: string; summary?: unknown; created?: number } = {},
): SessionMessageUser {
const id = input.id ?? userID
const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
const message = {
info: {
id,
sessionID,
role: "user",
time: { created: input.created ?? 1700000000000 },
summary: input.summary ?? { diffs: [] },
agent: "build",
model,
},
parts: seeds.map((part) => ({
...part,
sessionID,
messageID: id,
})),
} satisfies Extract<TimelineMessage, { info: { role: "user" } }>
decodeMessage(message, decodeOptions)
return message
return {
id,
type: "user",
time: { created: input.created ?? 1700000000000 },
text: seeds.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
files: seeds.flatMap((part) => {
if (part.type !== "file") return []
const mention = part.source?.text
? { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end }
: undefined
return [
{
data: part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? "",
mime: part.mime,
source: part.url.startsWith("data:")
? ({ type: "inline" } as const)
: ({ type: "uri", uri: part.source?.path ?? part.url } as const),
...(part.filename ? { name: part.filename } : {}),
...(mention ? { mention } : {}),
},
]
}),
agents: seeds.flatMap((part) => {
if (part.type !== "agent") return []
return [
{
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
},
]
}),
...(input.summary === undefined ? {} : { metadata: { summary: input.summary as JsonValue } }),
}
}
export function assistantMessage(
@@ -391,49 +490,43 @@ export function assistantMessage(
id?: string
parentID?: string
completed?: boolean
error?: AssistantMessage["error"]
error?: SessionStructuredError
created?: number
} = {},
): Extract<TimelineMessage, { info: { role: "assistant" } }> {
): SessionMessageAssistant {
if (input.error && (typeof input.error.type !== "string" || typeof input.error.message !== "string"))
throw new Error("Invalid assistant error")
const id = input.id ?? assistantID
const message = {
info: {
id,
sessionID,
role: "assistant",
time: {
created: input.created ?? 1700000001000,
...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }),
},
parentID: input.parentID ?? userID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
...(input.error ? { error: input.error } : {}),
},
parts: parts.map((part) => ({ ...part, sessionID, messageID: id })),
} satisfies Extract<TimelineMessage, { info: { role: "assistant" } }>
decodeMessage(message, decodeOptions)
return message
const created = input.created ?? 1700000001000
const ordinals = { text: 0, reasoning: 0 }
const content = parts.map((part) => messageContent(part, id, ordinals))
nextOrdinals.set(id, ordinals)
return {
id,
type: "assistant",
metadata: { parentID: input.parentID ?? userID },
time: { created, ...(input.completed === false ? {} : { completed: created + 1_000 }) },
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
agent: "build",
content,
cost: 0.01,
tokens,
...(input.completed === false ? {} : { finish: "stop" as const }),
...(input.error ? { error: input.error } : {}),
}
}
export function userText(
text: string,
input: Partial<Omit<Extract<PartSeed<"user">, { type: "text" }>, "type" | "text">> = {},
): Extract<PartSeed<"user">, { type: "text" }> {
export function userText(text: string, input: Partial<Omit<TextSeed, "type" | "text">> = {}): TextSeed {
return { id: "prt_user_text", type: "text", text, ...input }
}
export function textPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "text" }> {
export function textPart(id: string, text: string): TextSeed {
partRef(id, assistantID, "text")
return { id, type: "text", text }
}
export function reasoningPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "reasoning" }> {
export function reasoningPart(id: string, text: string): ReasoningSeed {
partRef(id, assistantID, "reasoning")
return { id, type: "reasoning", text, time: { start: 1700000001000 } }
}
@@ -443,35 +536,35 @@ export function toolPart(
state: "pending",
input: Record<string, unknown>,
options?: ToolOptions<"pending">,
): Omit<ToolPart, "sessionID" | "messageID">
): ToolSeed
export function toolPart(
id: string,
tool: string,
state: "running",
input: Record<string, unknown>,
options?: ToolOptions<"running">,
): Omit<ToolPart, "sessionID" | "messageID">
): ToolSeed
export function toolPart(
id: string,
tool: string,
state: "completed",
input: Record<string, unknown>,
options?: ToolOptions<"completed">,
): Omit<ToolPart, "sessionID" | "messageID">
): ToolSeed
export function toolPart(
id: string,
tool: string,
state: "error",
input: Record<string, unknown>,
options?: ToolOptions<"error">,
): Omit<ToolPart, "sessionID" | "messageID">
): ToolSeed
export function toolPart(
id: string,
tool: string,
state: ToolStatus,
input: Record<string, unknown>,
options: ToolOptions<ToolStatus> = {},
): Omit<ToolPart, "sessionID" | "messageID"> {
): ToolSeed {
const base = { id, type: "tool" as const, callID: id, tool }
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
if (state === "running")
@@ -509,12 +602,7 @@ export function toolPart(
}
}
export function shell(
id: string,
state: ToolStatus,
output = "",
command = `echo ${id}`,
): Omit<ToolPart, "sessionID" | "messageID"> {
export function shell(id: string, state: ToolStatus, output = "", command = `echo ${id}`): ToolSeed {
if (state === "pending") return toolPart(id, "bash", state, { command })
if (state === "running")
return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
@@ -523,7 +611,7 @@ export function shell(
return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
}
export function completedAssistantInfo(info: AssistantMessage): AssistantMessage {
export function completedAssistantInfo(info: SessionMessageAssistant): SessionMessageAssistant {
return { ...info, time: { ...info.time, completed: 1700000003000 } }
}
@@ -551,16 +639,200 @@ export function session(input: Partial<Session> = {}): Session {
}
}
function eventSchema<
const Type extends TimelinePayload["type"],
const Properties extends Schema.Codec<unknown, unknown>,
>(type: Type, properties: Properties) {
return Schema.Struct({
directory: Schema.String,
project: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }),
})
function messageContent(
part: PartSeed<"assistant">,
messageID: string,
ordinals: { text: number; reasoning: number },
): SessionMessageAssistant["content"][number] {
if (part.type === "tool") {
partRefs.set(part.id, { messageID, type: part.type })
toolStates.set(part.callID, part.state.status)
} else {
partRefs.set(part.id, { messageID, type: part.type, ordinal: ordinals[part.type]++ })
startedParts.add(part.id)
}
if (part.type === "text") return { type: "text", text: part.text }
if (part.type === "reasoning")
return {
type: "reasoning",
text: part.text,
state: jsonRecord(part.metadata),
time: part.time
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
: undefined,
}
const state = part.state
const time = "time" in state ? state.time : undefined
const completed = state.status === "completed" || state.status === "error" ? state.time.end : undefined
const base = {
type: "tool" as const,
id: part.callID,
name: part.tool,
time: {
created: time?.start ?? 1700000001000,
...(time?.start === undefined ? {} : { ran: time.start }),
...(completed === undefined ? {} : { completed }),
},
...(part.executed === undefined ? {} : { executed: part.executed }),
...(part.providerState ? { providerState: jsonRecord(part.providerState) } : {}),
...(part.providerResultState ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
}
if (state.status === "pending") return { ...base, state: { status: "streaming", input: state.raw } }
if (state.status === "running")
return {
...base,
state: { status: "running", input: jsonRecord(state.input), metadata: jsonRecord(state.metadata) },
}
if (state.status === "error")
return {
...base,
state: {
status: "error",
input: jsonRecord(state.input),
error: { type: "ToolError", message: state.error },
metadata: jsonRecord(state.metadata),
},
}
return {
...base,
state: {
status: "completed",
input: jsonRecord(state.input),
content: [{ type: "text", text: state.output }],
metadata: jsonRecord(state.metadata),
},
}
}
function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[] {
const previous = toolStates.get(part.callID)
if (previous === "completed" || previous === "error") return []
const events: OpenCodeEvent[] = []
if (!previous) {
events.push(
makeEvent("session.tool.input.started", {
sessionID,
assistantMessageID: messageID,
id: part.callID,
name: part.tool,
}),
)
}
if (part.state.status === "pending") {
toolStates.set(part.callID, part.state.status)
return events
}
if (!previous || previous === "pending") {
events.push(
makeEvent("session.tool.input.ended", {
sessionID,
assistantMessageID: messageID,
id: part.callID,
text: JSON.stringify(part.state.input),
}),
makeEvent("session.tool.called", {
sessionID,
assistantMessageID: messageID,
id: part.callID,
input: part.state.input,
executed: part.executed ?? true,
state: jsonRecord(part.providerState),
}),
)
}
if (part.state.status === "running") {
if (previous === "running" || Object.keys(part.state.metadata).length)
events.push(
makeEvent("session.tool.progress", {
sessionID,
assistantMessageID: messageID,
id: part.callID,
metadata: jsonRecord(part.state.metadata),
}),
)
toolStates.set(part.callID, part.state.status)
return events
}
if (part.state.status === "error") {
events.push(
makeEvent("session.tool.failed", {
sessionID,
assistantMessageID: messageID,
id: part.callID,
error: { type: "ToolError", message: part.state.error },
metadata: jsonRecord(part.state.metadata),
executed: part.executed ?? true,
resultState: jsonRecord(part.providerResultState),
}),
)
toolStates.set(part.callID, part.state.status)
return events
}
events.push(
makeEvent("session.tool.success", {
sessionID,
assistantMessageID: messageID,
id: part.callID,
content: [{ type: "text", text: part.state.output }],
metadata: jsonRecord(part.state.metadata),
executed: part.executed ?? true,
resultState: jsonRecord(part.providerResultState),
}),
)
toolStates.set(part.callID, part.state.status)
return events
}
function partRef(id: string, messageID: string, type: PartRef["type"]): PartRef {
const current = partRefs.get(id)
if (current) return current
if (type === "tool") {
const ref = { messageID, type } satisfies PartRef
partRefs.set(id, ref)
return ref
}
const next = nextOrdinals.get(messageID) ?? { text: 0, reasoning: 0 }
const ref = { messageID, type, ordinal: next[type]++ } satisfies PartRef
nextOrdinals.set(messageID, next)
partRefs.set(id, ref)
return ref
}
function makeEvent<Type extends OpenCodeEvent["type"]>(
type: Type,
data: Extract<OpenCodeEvent, { type: Type }>["data"],
): OpenCodeEvent {
const id = `evt_timeline_${String(++eventSequence).padStart(4, "0")}`
const base = { id, created: 1700000002000 + eventSequence, type, data, location: { directory } }
const definition = EventManifest.ServerDefinitions.find((definition) => definition.type === type)
if (!definition) throw new Error(`Unknown timeline event: ${type}`)
const input =
definition.durability === "durable"
? {
...base,
durable: { aggregateID: sessionID, seq: ++durableSequence, version: definition.durable.version },
}
: base
return Schema.decodeUnknownSync(definition)(input) as unknown as OpenCodeEvent
}
function jsonRecord(value: Record<string, unknown> | undefined): Record<string, JsonValue> {
if (!value) return {}
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => {
const next = jsonValue(item)
return next === undefined ? [] : [[key, next]]
}),
)
}
function jsonValue(value: unknown): JsonValue | undefined {
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (Array.isArray(value)) return value.map((item) => jsonValue(item) ?? null)
if (!value || typeof value !== "object") return
return jsonRecord(value as Record<string, unknown>)
}
function provider() {
@@ -6,7 +6,16 @@ import {
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import { assistantMessage, setupTimeline, shell, textPart, toolPart, userMessage, waitForVisualSettle } from "./fixture"
import {
assistantMessage,
renderedPartID,
setupTimeline,
shell,
textPart,
toolPart,
userMessage,
waitForVisualSettle,
} from "./fixture"
test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => {
const shellID = "prt_interaction_01_shell"
@@ -20,11 +29,20 @@ test("expands and collapses a long completed shell without overlap", async ({ pa
cpuRate: 4,
seedHistory: true,
})
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
const trigger = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"] [data-slot="collapsible-trigger"]`)
await waitForVisualSettle(page, [
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
shell: {
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
following: {
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
const plan = visualPlan(regions, [
{ type: "required", regions: ["shell", "following"] },
@@ -76,7 +94,7 @@ test("expands and collapses a completed context group without overlap", async ({
seedHistory: true,
})
const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`)
await waitForVisualSettle(page, [group, `[data-timeline-part-id="${followingID}"]`])
await waitForVisualSettle(page, [group, `[data-timeline-part-id="${renderedPartID(followingID)}"]`])
for (const [name, expanded] of [
["context-expand", true],
["context-collapse", false],
@@ -85,7 +103,7 @@ test("expands and collapses a completed context group without overlap", async ({
const regions = defineVisualRegions({
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
@@ -144,11 +162,22 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
cpuRate: 4,
seedHistory: true,
})
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
await waitForVisualSettle(page, [`[data-timeline-part-id="${editID}"]`, `[data-timeline-part-id="${followingID}"]`])
const trigger = page
.locator(`[data-timeline-part-id="${renderedPartID(editID)}"] [data-slot="collapsible-trigger"]`)
.first()
await waitForVisualSettle(page, [
`[data-timeline-part-id="${renderedPartID(editID)}"]`,
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
])
const regions = defineVisualRegions({
edit: { selector: `[data-timeline-part-id="${editID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
edit: {
selector: `[data-timeline-part-id="${renderedPartID(editID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
following: {
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await trigger.click()
@@ -173,64 +202,6 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
)
})
test("shows all and expands historical diff summary without overlap", async ({ page }, testInfo) => {
const firstUser = userMessage(undefined, {
summary: {
diffs: Array.from({ length: 12 }, (_, index) => ({
file: `src/diff-${index}.ts`,
status: "modified",
additions: 1,
deletions: 1,
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
})),
},
})
const nextUserID = "msg_2000_diff_interaction_user"
await setupTimeline(page, {
messages: [
firstUser,
assistantMessage(),
userMessage(undefined, { id: nextUserID, created: 1700000010000 }),
assistantMessage([], {
id: "msg_2001_diff_interaction_assistant",
parentID: nextUserID,
created: 1700000011000,
}),
],
cpuRate: 4,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
const diff = page.locator('[data-timeline-row="DiffSummary"]')
const following = page.locator(`[data-message-id="${nextUserID}"]`).first()
await expect(diff).toBeVisible()
const regions = defineVisualRegions({
diff: { selector: '[data-timeline-row="DiffSummary"]' },
following: { selector: `[data-message-id="${nextUserID}"]` },
})
await startVisualProbe(page, regions)
await page.getByText(/show all/i).click()
await page.waitForTimeout(500)
await diff.locator('[data-slot="session-turn-diff-trigger"]').first().click()
await page.waitForTimeout(900)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"diff-summary-expand",
trace,
visualPlan(regions, [
{ type: "required", regions: ["diff", "following"] },
{ type: "unique", regions: ["diff", "following"] },
{ type: "stable", regions: ["diff", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
{ type: "label-stability", regions: "all" },
{ type: "flow", regions: ["diff", "following"] },
]),
)
})
function lines(count: number) {
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
}
@@ -14,6 +14,7 @@ import {
partDelta,
partUpdated,
reasoningPart,
renderedPartID,
setupTimeline,
shell,
status,
@@ -50,7 +51,7 @@ test.describe("timeline visual lifecycle stability", () => {
prt_shell_long: shellRegion(ids[2]),
following: shellRegion(followingID),
})
await waitForVisualSettle(page, [`[data-timeline-part-id="${followingID}"]`])
await waitForVisualSettle(page, [`[data-timeline-part-id="${renderedPartID(followingID)}"]`])
await startVisualProbe(page, regions)
await timeline.sendAll([
{ event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 },
@@ -60,7 +61,7 @@ test.describe("timeline visual lifecycle stability", () => {
{ event: partUpdated(shell(ids[1]!, "completed", lines(2))), delay: 260 },
{ event: partUpdated(shell(ids[2]!, "running", lines(50))), delay: 100 },
{ event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 },
{ event: messageUpdated(completedAssistantInfo(assistant.info)), delay: 100 },
{ event: messageUpdated(completedAssistantInfo(assistant)), delay: 100 },
{ event: status("idle"), delay: 700 },
])
const trace = await stopVisualProbe<keyof typeof regions>(page)
@@ -84,9 +85,11 @@ test.describe("timeline visual lifecycle stability", () => {
{ perMarker: true },
),
)
await expect(page.locator(`[data-timeline-part-id="${ids[2]}"] [data-slot="bash-pre"]`)).toContainText("line 50")
await expect(
page.locator(`[data-timeline-part-id="${renderedPartID(ids[2])}"] [data-slot="bash-pre"]`),
).toContainText("line 50")
const short = page.locator(`[data-timeline-part-id="${ids[1]}"]`)
const short = page.locator(`[data-timeline-part-id="${renderedPartID(ids[1])}"]`)
await short.locator('[data-slot="collapsible-trigger"]').click()
await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await timeline.send(partUpdated(textPart("prt_late_sibling", "A later sibling rerender.")), 250)
@@ -106,26 +109,31 @@ test.describe("timeline visual lifecycle stability", () => {
})
await timeline.send(status("busy"), 120)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
const initialReasoning = reasoningPart(reasoningID, "")
const initialText = textPart(textID, "Starting")
const regions = defineVisualRegions({
thinking: { selector: '[data-timeline-row="Thinking"]' },
reasoning: {
selector: `[data-timeline-part-id="${reasoningID}"]`,
selector: `[data-timeline-part-id="${renderedPartID(reasoningID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
text: {
selector: `[data-timeline-part-id="${renderedPartID(textID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(reasoningPart(reasoningID, "")), 100)
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(initialReasoning), 100)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160)
await timeline.waitForPart(reasoningID)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.send(partUpdated(textPart(textID, "Starting")), 100)
await timeline.send(partUpdated(initialText), 100)
await timeline.send(partDelta(textID, " **stable"), 90)
await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130)
await timeline.send(partDelta(textID, "](https://example.com)."), 220)
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 120)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 120)
await timeline.send(status("idle"), 500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
@@ -144,7 +152,7 @@ test.describe("timeline visual lifecycle stability", () => {
{ type: "flow", regions: ["reasoning", "text"] },
]),
)
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("stable output")
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText("stable output")
})
})
@@ -153,5 +161,5 @@ function lines(count: number) {
}
function shellRegion(id: string) {
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
return { selector: `[data-timeline-part-id="${renderedPartID(id)}"]`, closest: '[data-timeline-row="AssistantPart"]' }
}
@@ -6,14 +6,14 @@ import {
stopVisualProbe,
visualPlan,
} from "../../utils/visual-stability"
import { assistantMessage, setupTimeline, textPart, userMessage } from "./fixture"
import { assistantMessage, renderedPartID, setupTimeline, textPart, userMessage } from "./fixture"
test("detects blanking caused by ancestor opacity", async ({ page }) => {
const partID = "prt_oracle_ancestor_opacity"
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
const row = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`).first()
const regions = defineVisualRegions({
content: { selector: `[data-timeline-part-id="${partID}"]` },
content: { selector: `[data-timeline-part-id="${renderedPartID(partID)}"]` },
})
await startVisualProbe(page, regions)
await row.evaluate((element) => {
@@ -41,13 +41,13 @@ test("detects blanking caused by ancestor opacity", async ({ page }) => {
test("detects root opacity when probing descendant opacity", async ({ page }) => {
const partID = "prt_oracle_descendant_opacity"
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
const row = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`).first()
await row.evaluate((element) => {
element.innerHTML = '<span data-probe-opacity="true">Visible content</span>'
})
const regions = defineVisualRegions({
content: {
selector: `[data-timeline-part-id="${partID}"]`,
selector: `[data-timeline-part-id="${renderedPartID(partID)}"]`,
opacitySelectors: ['[data-probe-opacity="true"]'],
},
})
@@ -9,6 +9,7 @@ import {
import {
assistantMessage,
partUpdated,
renderedPartID,
setupTimeline,
shell,
textPart,
@@ -34,8 +35,14 @@ test("does not reverse visible rows when the user wheels during shell remeasurem
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
shell: {
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
following: {
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80)
@@ -143,8 +150,8 @@ test("tracks keyboard scrolling from a focused timeline descendant", async ({ pa
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const row = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
const row = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"]`).first()
const trigger = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"] [data-slot="collapsible-trigger"]`)
await row.evaluate((element) => element.setAttribute("tabindex", "0"))
await row.focus()
for (let index = 0; index < 3; index++) {
@@ -182,7 +189,7 @@ test("does not claim keyboard scrolling owned by a nested scrollable", async ({
seedHistory: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const nested = page.locator(`[data-timeline-part-id="${shellID}"] [data-scrollable]`)
const nested = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"] [data-scrollable]`)
await nested.evaluate((element) => (element.scrollTop = element.scrollHeight))
await nested.focus()
await page.waitForFunction(() => {
@@ -209,7 +216,7 @@ test("does not claim keyboard scrolling owned by a nested scrollable", async ({
await nested.press("PageUp")
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore)
const nonOverflowing = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
const nonOverflowing = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"]`).first()
await nonOverflowing.evaluate((element) => {
element.setAttribute("data-scrollable", "")
element.setAttribute("tabindex", "0")
@@ -238,12 +245,18 @@ test("jump to latest lands on stable final rows after offscreen growth", async (
)
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300)
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
shell: {
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
following: {
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await page.getByRole("button", { name: /Jump to latest/i }).click()
await expect(page.locator(`[data-timeline-part-id="${followingID}"]`)).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(followingID)}"]`)).toBeVisible()
await page.waitForTimeout(600)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
@@ -277,8 +290,14 @@ test("handles a single row taller than the viewport", async ({ page }, testInfo)
seedHistory: true,
})
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
shell: {
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
following: {
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700)
@@ -9,6 +9,7 @@ import {
import {
assistantMessage,
partUpdated,
renderedPartID,
setupTimeline,
shell,
textPart,
@@ -68,13 +69,16 @@ for (const profile of profiles) {
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
await waitForVisualSettle(page, [
`[data-timeline-part-id="${shellID}"]`,
`[data-timeline-part-id="${followingID}"]`,
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
shell: {
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
@@ -120,10 +124,19 @@ test("keeps following row stable when a collapsed shell receives 50 lines", asyn
cpuRate: 4,
seedHistory: true,
})
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
await waitForVisualSettle(page, [
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
shell: {
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
following: {
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240)
@@ -164,10 +177,19 @@ test("keeps rows stable when a running shell becomes an error", async ({ page },
cpuRate: 4,
seedHistory: true,
})
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
await waitForVisualSettle(page, [
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
shell: {
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
following: {
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await timeline.send(
@@ -215,16 +237,20 @@ test("keeps rows stable when later text arrives before shell output", async ({ p
cpuRate: 4,
seedHistory: true,
})
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`])
const following = textPart(followingID, "Later assistant content arrived before shell output.")
await waitForVisualSettle(page, [`[data-timeline-part-id="${renderedPartID(shellID)}"]`])
const regions = defineVisualRegions({
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
shell: {
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
following: {
selector: `[data-timeline-part-id="${followingID}"]`,
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(textPart(followingID, "Later assistant content arrived before shell output.")), 240)
await timeline.send(partUpdated(following), 240)
await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300)
await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600)
const trace = await stopVisualProbe<keyof typeof regions>(page)
@@ -11,8 +11,8 @@ import {
partUpdated,
session,
sessionID,
renderedPartID,
setupTimeline,
textPart,
toolPart,
userMessage,
} from "./fixture"
@@ -27,7 +27,7 @@ test("adds a task child-session link without replacing the task row", async ({ p
cpuRate: 4,
})
const regions = defineVisualRegions({
task: { selector: `[data-timeline-part-id="${taskID}"] [data-slot="collapsible-trigger"]` },
task: { selector: `[data-timeline-part-id="${renderedPartID(taskID)}"] [data-slot="collapsible-trigger"]` },
})
await startVisualProbe(page, regions)
await timeline.send(
@@ -53,54 +53,3 @@ test("adds a task child-session link without replacing the task row", async ({ p
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
).toBeVisible()
})
test("changes generic tool arguments without replacing the row", async ({ page }, testInfo) => {
const toolID = "prt_generic_mutation"
const followingID = "prt_generic_mutation_following"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
toolPart(toolID, "mcp_probe", "running", { target: "one", count: 1 }),
textPart(followingID, "Following generic tool"),
],
{ completed: false },
),
],
cpuRate: 4,
})
const regions = defineVisualRegions({
tool: { selector: `[data-timeline-part-id="${toolID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(
partUpdated(toolPart(toolID, "mcp_probe", "running", { target: "two", count: 2, mode: "deep" })),
200,
)
await timeline.send(
partUpdated(toolPart(toolID, "mcp_probe", "completed", { target: "two", count: 2, mode: "deep" })),
400,
)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"generic-mutation",
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["tool", "following"] },
{ type: "unique", regions: ["tool", "following"] },
{ type: "stable", regions: ["tool", "following"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
{ type: "flow", regions: ["tool", "following"] },
],
{ perMarker: true },
),
)
})
@@ -12,6 +12,7 @@ import {
partUpdated,
session,
sessionID,
renderedPartID,
setupTimeline,
status,
textPart,
@@ -48,8 +49,8 @@ test.describe("timeline tool state stability", () => {
})
await timeline.send(status("busy"), 120)
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(todoID)}"]`)).toHaveCount(0)
const regionIDs = [
"prt_state_webfetch",
@@ -104,8 +105,10 @@ test.describe("timeline tool state stability", () => {
{ type: "label-stability", regions: "all" },
]),
)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toContainText(
"Keep it stable",
)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(todoID)}"]`)).toHaveCount(0)
await expect(
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
).toBeVisible()
@@ -145,7 +148,7 @@ test.describe("timeline tool state stability", () => {
},
context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' },
following: {
selector: '[data-timeline-part-id="prt_ctx_following"]',
selector: `[data-timeline-part-id="${renderedPartID("prt_ctx_following")}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
@@ -194,5 +197,5 @@ function questionInput() {
}
function toolRegion(id: string) {
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
return { selector: `[data-timeline-part-id="${renderedPartID(id)}"]`, closest: '[data-timeline-row="AssistantPart"]' }
}
@@ -7,13 +7,13 @@ import {
visualPlan,
} from "../../utils/visual-stability"
import {
assistantID,
assistantMessage,
completedAssistantInfo,
event,
messageUpdated,
partDelta,
partUpdated,
renderedPartID,
setupTimeline,
shell,
status,
@@ -22,35 +22,6 @@ import {
userMessage,
} from "./fixture"
test("keeps unchanged siblings stable while a middle part is inserted and removed", async ({ page }, testInfo) => {
const firstID = "prt_mutation_01_first"
const middleID = "prt_mutation_02_middle"
const lastID = "prt_mutation_03_last"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([textPart(firstID, "First stable row"), textPart(lastID, "Last stable row")], {
completed: false,
}),
],
cpuRate: 4,
})
const regions = defineVisualRegions({
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
})
await startVisualProbe(page, regions)
await timeline.send(partUpdated(textPart(middleID, "Inserted middle row. ".repeat(12))), 350)
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toBeVisible()
await timeline.send(
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: middleID }),
500,
)
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toHaveCount(0)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "middle-insert-remove", trace, stablePairPlan(regions, 1))
})
test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => {
const textID = "prt_text_reconcile"
const followingID = "prt_text_reconcile_following"
@@ -59,14 +30,20 @@ test("streams text through growth, canonical replacement, and completion", async
})
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
const regions = defineVisualRegions({
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
text: {
selector: `[data-timeline-part-id="${renderedPartID(textID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
following: {
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await timeline.send(partDelta(textID, " streamed content"), 100)
await timeline.send(partDelta(textID, "\n\n- item one\n- item two\n- item three"), 180)
await timeline.send(partUpdated(textPart(textID, "Canonical replacement with a shorter final paragraph.")), 200)
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 500)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
@@ -105,17 +82,23 @@ test("inserts a completed question between stable rows", async ({ page }, testIn
],
cpuRate: 4,
})
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toHaveCount(0)
const regions = defineVisualRegions({
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
first: {
selector: `[data-timeline-part-id="${renderedPartID(firstID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
last: {
selector: `[data-timeline-part-id="${renderedPartID(lastID)}"]`,
closest: '[data-timeline-row="AssistantPart"]',
},
})
await startVisualProbe(page, regions)
await timeline.send(
partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })),
600,
)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toBeVisible()
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0))
})
@@ -132,8 +115,8 @@ test("replaces thinking with an assistant error without a blank turn", async ({
await startVisualProbe(page, regions)
await timeline.send(
messageUpdated({
...assistant.info,
error: { name: "APIError", data: { message: "Provider failed visibly", isRetryable: false } },
...assistant,
error: { type: "APIError", message: "Provider failed visibly" },
}),
500,
)
@@ -203,59 +186,6 @@ test("updates retry attempts and long provider messages without remounting the r
)
})
test("reducer-hardening: removes a historical turn one message at a time without moving a visible lower anchor twice", async ({
page,
}, testInfo) => {
const removeUserID = "msg_0500_remove_user"
const removeAssistantID = "msg_0501_remove_assistant"
const anchorUserID = "msg_2000_anchor_user"
const timeline = await setupTimeline(page, {
messages: [
userMessage(undefined, { id: removeUserID, created: 1690000000000 }),
assistantMessage([textPart("prt_remove_text", "Removed historical content. ".repeat(15))], {
id: removeAssistantID,
parentID: removeUserID,
created: 1690000001000,
}),
userMessage(undefined, { id: anchorUserID, created: 1700000000000 }),
assistantMessage([textPart("prt_anchor_text", "Visible anchor response")], {
id: "msg_2001_anchor_assistant",
parentID: anchorUserID,
created: 1700000001000,
}),
],
cpuRate: 4,
})
const regions = defineVisualRegions({
anchor: { selector: `[data-timeline-row="UserMessage"][data-message-id="${anchorUserID}"]` },
})
await startVisualProbe(page, regions)
await timeline.send(
event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeAssistantID }),
200,
)
await timeline.send(event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeUserID }), 500)
const trace = await stopVisualProbe<keyof typeof regions>(page)
await reportVisualStability(
testInfo,
"historical-turn-remove",
trace,
visualPlan(
regions,
[
{ type: "required", regions: ["anchor"] },
{ type: "unique", regions: ["anchor"] },
{ type: "stable", regions: ["anchor"] },
{ type: "opacity", regions: "all" },
{ type: "continuity", regions: "all" },
{ type: "motion", regions: "all", maxPositionReversals: 0 },
{ type: "label-stability", regions: "all" },
],
{ perMarker: true },
),
)
})
function stablePairPlan(
regions: Record<"first" | "last", { selector: string; closest?: string }>,
maxPositionReversals: number,
@@ -1,3 +1,4 @@
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import type { Page } from "@playwright/test"
import { expectSessionTitle } from "../../utils/waits"
import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -11,42 +12,35 @@ type ParentHydrationBenchmarkMode = "natural" | "candidate"
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
const userID = "msg_parent_hydration_user"
const userSeed = fixture.messages[fixture.targetID][0] as SessionMessageUser
const user = {
...fixture.messages[fixture.targetID][0]!,
info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
...part,
id: `prt_parent_hydration_user_${index}`,
messageID: userID,
})),
}
const assistantSeed = fixture.messages[fixture.targetID][3]!
...userSeed,
id: userID,
time: { created: 1700001000000 },
} satisfies SessionMessageInfo
const assistantSeed = fixture.messages[fixture.targetID][3] as SessionMessageAssistant
const assistants = Array.from({ length: 14 }, (_, index) => {
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
return {
...assistantSeed,
info: {
...assistantSeed.info,
id: messageID,
parentID: userID,
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
},
parts: assistantSeed.parts.map((part, partIndex) => ({
...part,
id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
messageID,
})),
}
id: messageID,
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
content: assistantSeed.content.map((part, partIndex) =>
part.type === "tool"
? { ...part, id: `call_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}` }
: part,
),
} satisfies SessionMessageInfo
})
const messages = [user, ...assistants]
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
const lastID = userID
const lastAssistant = assistants.at(-1)!
const lastPart = lastAssistant.parts.at(-1)!
const lastPart = lastAssistant.content.at(-1)!
const lastPartID =
lastPart.type === "tool"
? lastPart.id
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
: `${lastAssistant.id}:${lastPart.type}:${lastAssistant.content.filter((part) => part.type === lastPart.type).length - 1}`
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
benchmark.setTimeout(180_000)
@@ -107,30 +101,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
},
pageMessages: (sessionID, limit, before) => {
const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
const end = before ? items.findIndex((message) => message.info.id === before) : items.length
const end = before ? items.findIndex((message) => message.id === before) : items.length
const start = Math.max(0, end - limit)
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.id : undefined }
},
})
await page.route(`**/session/${fixture.targetID}`, (route) => {
const current = new URL(route.request().url()).pathname.startsWith("/api/")
return route.fulfill({
await page.route(`**/api/session/${fixture.targetID}`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
current
? {
data: {
...target,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
location: { directory: target.directory },
},
}
: target,
),
})
})
body: JSON.stringify({
data: {
...target,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
location: { directory: target.directory },
},
}),
}),
)
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
@@ -148,8 +137,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
{ href, title: target.title },
)
const metrics = await measureSessionSwitch(page, {
destinationIDs: messages.map((message) => message.info.id),
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
destinationIDs: messages.map((message) => message.id),
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.id),
lastID,
requiredPartID: lastPartID,
requireBottomAnchor: false,
@@ -32,8 +32,8 @@ benchmark("samples cached session repaint after the click", async ({ page, repor
await installCachedRepaintProbe(page, {
targetHref: stressSessionHref(fixture.targetID),
destination: fixture.messages[fixture.targetID].map((message) => message.info.id),
source: fixture.messages[fixture.sourceID].map((message) => message.info.id),
destination: fixture.messages[fixture.targetID].map((message) => message.id),
source: fixture.messages[fixture.sourceID].map((message) => message.id),
last: fixture.expected.targetMessageIDs.at(-1)!,
windowMs: 1_000,
})
@@ -60,8 +60,8 @@ async function trial(page: Page, mode: "cold" | "hot", reviewPane: "closed" | "o
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
}
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.id)
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.id)
const lastID = fixture.expected.targetMessageIDs.at(-1)!
const href = stressSessionHref(fixture.targetID)
const result = await measureSessionSwitch(page, {
@@ -1,4 +1,5 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
@@ -10,37 +11,20 @@ const sessionID = "ses_timeline_state_regression"
const userMessageID = "msg_user_regression"
const assistantMessageID = "msg_assistant_regression"
const editPartID = "prt_0001_edit"
export const textPartID = "prt_9999_text"
export const textPartID = `${assistantMessageID}:text:0`
const title = "Timeline collapse state regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
type EventPayload = OpenCodeEvent
const userMessage = {
info: {
id: userMessageID,
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [
{
id: "prt_user_text",
sessionID,
messageID: userMessageID,
type: "text",
text: "Please edit the file.",
},
],
}
id: userMessageID,
type: "user",
time: { created: 1700000000000 },
text: "Please edit the file.",
} satisfies SessionMessageInfo
const editPart = {
const editPart: ToolSeed = {
id: editPartID,
sessionID,
messageID: assistantMessageID,
@@ -66,32 +50,16 @@ const editPart = {
},
}
const streamedTextPart = {
id: textPartID,
sessionID,
messageID: assistantMessageID,
type: "text",
text: "Streaming added a later assistant text part.",
}
const assistantMessage = {
info: {
id: assistantMessageID,
sessionID,
role: "assistant",
time: { created: 1700000001000 },
parentID: userMessageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
},
parts: [editPart],
}
id: assistantMessageID,
type: "assistant",
time: { created: 1700000001000 },
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
agent: "build",
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
content: [toolContent(editPart)],
} satisfies SessionMessageInfo
export async function setupTimelineBenchmark(
page: Page,
@@ -105,7 +73,7 @@ export async function setupTimelineBenchmark(
const events: EventPayload[] = []
let eventBatch = options.eventBatch
const currentUserMessage = options.turnDiffs
? { ...userMessage, info: { ...userMessage.info, summary: { diffs: options.turnDiffs } } }
? { ...userMessage, metadata: { diffs: options.turnDiffs as JsonValue } }
: userMessage
await mockOpenCodeServer(page, {
directory,
@@ -138,7 +106,8 @@ export async function setupTimelineBenchmark(
await page.setViewportSize({ width: 1366, height: 768 })
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expectAppVisible(scroller)
return {
@@ -182,34 +151,27 @@ export async function setupTimelineBenchmark(
}
}
export function buildInitialStreamEvent(deltaCount: number): EventPayload {
return {
directory,
payload: {
type: "message.part.updated",
properties: {
part: {
...streamedTextPart,
text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
},
},
},
}
export function buildInitialStreamEvent(deltaCount: number): EventPayload[] {
return [
timelineEvent("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, true),
timelineEvent("session.text.delta", {
sessionID,
assistantMessageID,
ordinal: 0,
delta: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
}),
]
}
export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
return Array.from({ length: deltaCount }, (_, index) => ({
directory,
payload: {
type: "message.part.delta",
properties: {
messageID: assistantMessageID,
partID: textPartID,
field: "text",
delta: streamChunk(index + 1, deltaCount + 1),
},
},
}))
return Array.from({ length: deltaCount }, (_, index) =>
timelineEvent("session.text.delta", {
sessionID,
assistantMessageID,
ordinal: 0,
delta: streamChunk(index + 1, deltaCount + 1),
}),
)
}
function performanceTurn(index: number) {
@@ -315,48 +277,92 @@ function performanceTurn(index: number) {
},
]
: []),
]
] as unknown as ContentSeed[]
return [
{
info: {
id: userID,
sessionID,
role: "user",
time: { created: 1690000000000 + index * 2_000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [
{
id: `prt_0000_${suffix}_user`,
sessionID,
messageID: userID,
type: "text",
text: `Historical prompt ${index}`,
},
],
id: userID,
type: "user",
time: { created: 1690000000000 + index * 2_000 },
text: `Historical prompt ${index}`,
},
{
info: {
id: assistantID,
sessionID,
role: "assistant",
time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
parentID: userID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
finish: "stop",
},
parts,
id: assistantID,
type: "assistant",
time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
agent: "build",
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
finish: "stop",
content: parts.map((part) => {
if (part.type === "text") return { type: "text" as const, text: part.text }
if (part.type === "reasoning")
return {
type: "reasoning" as const,
text: part.text,
time: { created: part.time.start, completed: part.time.end },
}
return toolContent(part)
}),
},
]
] satisfies SessionMessageInfo[]
}
type ToolSeed = {
id?: string
sessionID?: string
messageID?: string
type: "tool"
callID: string
tool: string
state: {
status: string
input: Record<string, unknown>
output: string
title?: string
metadata: Record<string, unknown>
time: { start: number; end: number }
}
}
type ContentSeedBase = { id?: string; sessionID?: string; messageID?: string }
type ContentSeed =
| (ContentSeedBase & { type: "text"; text: string })
| (ContentSeedBase & { type: "reasoning"; text: string; time: { start: number; end: number } })
| ToolSeed
function toolContent(part: ToolSeed): SessionMessageAssistant["content"][number] {
return {
type: "tool",
id: part.callID,
name: part.tool,
time: { created: part.state.time.start, ran: part.state.time.start, completed: part.state.time.end },
state: {
status: "completed",
input: part.state.input as Record<string, JsonValue>,
content: [{ type: "text", text: part.state.output }],
metadata: part.state.metadata as Record<string, JsonValue>,
},
}
}
let eventSequence = 0
function timelineEvent<Type extends "session.text.started" | "session.text.delta">(
type: Type,
data: Extract<OpenCodeEvent, { type: Type }>["data"],
durable = false,
): Extract<OpenCodeEvent, { type: Type }> {
eventSequence++
return {
id: `evt_timeline_benchmark_${eventSequence}`,
created: 1700000002000 + eventSequence,
type,
data,
location: { directory },
...(durable ? { durable: { aggregateID: sessionID, seq: eventSequence, version: 1 } } : {}),
} as unknown as Extract<OpenCodeEvent, { type: Type }>
}
function historicalMarkdown(index: number) {
@@ -28,9 +28,22 @@ const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
type Message = { info: MessageInfo; parts: MessagePart[] }
type MessagePart = {
id: string
type: "text" | "reasoning" | "tool"
text?: string
time?: { start: number; end?: number }
callID?: string
tool?: string
state?: {
status: "completed"
input: Record<string, unknown>
output: string
title: unknown
metadata: Record<string, unknown>
time: { start: number; end: number }
}
}
function lorem(seed: number, length: number) {
let out = ""
@@ -48,54 +61,59 @@ function id(prefix: string, value: number) {
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
}
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo {
const messageID = id("msg_user", index)
return {
info: {
id: messageID,
sessionID,
role: "user",
time: { created: 1700000000000 + index * 10_000 },
summary: { diffs },
agent: "build",
model,
},
parts: [
{
id: id("prt_user_text", index),
sessionID,
messageID,
type: "text",
text: lorem(index, textLength),
},
],
id: messageID,
type: "user",
time: { created: 1700000000000 + index * 10_000 },
text: lorem(index, textLength),
metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined,
}
}
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
function assistantMessage(
_sessionID: string,
index: number,
_parentID: string,
parts: MessagePart[],
): SessionMessageInfo {
const messageID = id("msg_assistant", index)
return {
info: {
id: messageID,
sessionID,
role: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
parentID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
finish: "stop",
id: messageID,
type: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
agent: "build",
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
finish: "stop",
content: parts.map(messageContent),
}
}
function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] {
if (part.type === "text") return { type: "text", text: part.text ?? "" }
if (part.type === "reasoning")
return {
type: "reasoning",
text: part.text ?? "",
time: part.time
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
: undefined,
}
const state = part.state!
return {
type: "tool",
id: part.callID ?? part.id,
name: part.tool!,
time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
state: {
status: "completed",
input: state.input as Record<string, JsonValue>,
content: [{ type: "text", text: state.output }],
metadata: state.metadata as Record<string, JsonValue>,
},
parts: parts.map((part) => ({
...part,
sessionID,
messageID,
})),
}
}
@@ -200,7 +218,7 @@ function code(seed: number, lines: number, width = 32) {
).join("\n")
}
function turn(index: number): Message[] {
function turn(index: number): SessionMessageInfo[] {
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
const parts = [
@@ -241,7 +259,7 @@ function turn(index: number): Message[] {
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
: []),
]
return [user, assistantMessage(targetID, index, user.info.id, parts)]
return [user, assistantMessage(targetID, index, user.id, parts)]
}
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
@@ -267,19 +285,12 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
userMessage(childID, index + 2000, 120),
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
]).flat()
const messages: Record<string, Message[]> = {
const messages: Record<string, SessionMessageInfo[]> = {
[sourceID]: sourceMessages,
[targetID]: targetMessages,
[childID]: childMessages,
}
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text?.trim()
if (part.type === "reasoning") return !!part.text?.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
}
export const fixture = {
directory,
project: {
@@ -339,14 +350,19 @@ export const fixture = {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
childTitle: "Inspect child navigation",
sourceMessageIDs: sourceMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) => message.parts.filter(renderable).map((part) => part.id)),
sourceMessageIDs: sourceMessages.filter((message) => message.type === "user").map((message) => message.id),
targetMessageIDs: targetMessages.filter((message) => message.type === "user").map((message) => message.id),
childMessageIDs: childMessages.filter((message) => message.type === "user").map((message) => message.id),
targetPartIDs: targetMessages.flatMap((message) => {
if (message.type !== "assistant") return []
const ordinals = { text: 0, reasoning: 0 }
return message.content.flatMap((part) => {
if (part.type === "text") return part.text.trim() ? [`${message.id}:text:${ordinals.text++}`] : []
if (part.type === "reasoning")
return part.text.trim() ? [`${message.id}:reasoning:${ordinals.reasoning++}`] : []
return [part.id]
})
}),
},
}
@@ -355,12 +371,13 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
const end = before
? Math.max(
0,
messages.findIndex((message) => message.info.id === before),
messages.findIndex((message) => message.id === before),
)
: messages.length
const start = Math.max(0, end - limit)
return {
items: messages.slice(start, end),
cursor: start > 0 ? messages[start].info.id : undefined,
cursor: start > 0 ? messages[start].id : undefined,
}
}
import type { JsonValue, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
@@ -1,121 +1,6 @@
import { expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Page, Route } from "@playwright/test"
import { currentMessage, mockOpenCodeServer } from "../../utils/mock-server"
test("preserves current messages", () => {
const message = {
id: "msg_current",
type: "user",
time: { created: 1 },
text: "current",
files: [{ data: "e30=", mime: "application/json", source: { type: "inline" } }],
} satisfies SessionMessageInfo
expect(currentMessage(message)).toBe(message)
})
test("converts rich legacy messages to current message types", () => {
expect(
currentMessage({
info: { id: "msg_user", role: "user", time: { created: 1 } },
parts: [
{ type: "text", text: "Use @src/a.ts with @explore" },
{
type: "file",
mime: "application/json",
filename: "data.json",
url: "data:application/json;base64,e30=",
},
{
type: "file",
mime: "text/plain",
filename: "a.ts",
url: "src/a.ts",
source: { type: "file", text: { value: "@src/a.ts", start: 4, end: 13 } },
},
{ type: "agent", name: "explore", source: { value: "@explore", start: 19, end: 27 } },
],
}),
).toEqual({
id: "msg_user",
type: "user",
time: { created: 1 },
text: "Use @src/a.ts with @explore",
files: [
{ data: "e30=", mime: "application/json", name: "data.json", source: { type: "inline" } },
{
data: "",
mime: "text/plain",
name: "a.ts",
source: { type: "uri", uri: "src/a.ts" },
mention: { text: "@src/a.ts", start: 4, end: 13 },
},
],
agents: [{ name: "explore", mention: { text: "@explore", start: 19, end: 27 } }],
})
expect(
currentMessage({
info: {
id: "msg_assistant",
role: "assistant",
time: { created: 2, completed: 5 },
agent: "explore",
modelID: "model",
providerID: "provider",
variant: "high",
cost: 0.5,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
finish: "tool-calls",
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
},
parts: [
{ type: "text", text: "Answer" },
{ type: "reasoning", text: "Thinking", time: { start: 2, end: 3 } },
{
id: "prt_tool",
callID: "call_tool",
type: "tool",
tool: "read",
state: {
status: "completed",
input: { filePath: "src/a.ts" },
output: "contents",
metadata: { title: "a.ts" },
time: { start: 3, end: 4 },
},
},
],
}),
).toEqual({
id: "msg_assistant",
type: "assistant",
time: { created: 2, completed: 5 },
agent: "explore",
model: { id: "model", providerID: "provider", variant: "high" },
cost: 0.5,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
finish: "tool-calls",
error: { type: "MessageAbortedError", message: "Stopped" },
content: [
{ type: "text", text: "Answer" },
{ type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } },
{
type: "tool",
id: "call_tool",
name: "read",
time: { created: 3, ran: 3, completed: 4 },
state: {
status: "completed",
input: { filePath: "src/a.ts" },
content: [{ type: "text", text: "contents" }],
metadata: { title: "a.ts" },
},
},
],
})
})
import { mockOpenCodeServer } from "../../utils/mock-server"
test("applies message latency after a list response gate is released", async () => {
const events: string[] = []
@@ -5,7 +5,7 @@ import {
type VisualStabilityTrace,
} from "../../utils/visual-stability"
import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer"
import { legacyVisualPlan, visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
import { visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions"
function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace {
@@ -346,19 +346,6 @@ test("evaluates the typed invariant algebra over explicit observations", () => {
])
})
test("legacy plan adapter preserves analyzer messages and order", () => {
const input = trace([
frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
])
const options = { flow: ["changing", "following"], stable: ["changing"] }
expect(analyzeVisualObservations(input.samples, legacyVisualPlan(options))).toEqual(
analyzeVisualStability(input, options),
)
})
function frame(
at: number,
changing: VisualStabilityTrace["samples"][number]["regions"][string],
@@ -38,30 +38,6 @@ test("closing the active server's last tab opens the remaining server tab", asyn
await expect(page.getByText(sessionB.title).first()).toBeVisible()
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
expect(
requests.some((request) => {
const url = new URL(request)
return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory
}),
).toBe(true)
})
test("legacy session routes preserve an existing tab's server", async ({ page }) => {
await mockServers(page, [])
await page.addInitScript(
({ serverB, sessionB }) => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
)
},
{ serverB, sessionB: sessionB.id },
)
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`)
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
function session(id: string, directory: string, title: string) {
@@ -91,40 +67,26 @@ async function mockServers(page: Page, requests: string[]) {
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (url.pathname === "/project" || url.pathname === "/project/current") {
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/model/default")
return json(route, { location: { directory: current.directory }, data: null })
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
const project = {
id: current.projectID,
worktree: current.directory,
canonical: current.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/project" ? [project] : project)
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
}
if (url.pathname === "/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/api/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
@@ -6,6 +6,7 @@ import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/PromptInputV2Editing"
const projectID = "proj_prompt_input_v2_editing"
const sessionID = "ses_prompt_input_v2_editing"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
await mockOpenCodeServer(page, {
@@ -32,7 +33,7 @@ test("preserves the draft when a populated command menu triggers a built-in", as
],
pageMessages: () => ({ items: [] }),
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]')
await expectAppVisible(composer)
@@ -6,6 +6,7 @@ import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/PromptThinkingLevelRegression"
const projectID = "proj_prompt_thinking_level_regression"
const sessionID = "ses_prompt_thinking_level_regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("shows the V2 thinking level control while relevant", async ({ page }) => {
await mockOpenCodeServer(page, {
@@ -49,7 +50,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
],
pageMessages: () => ({ items: [] }),
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const composer = page.locator('[data-component="prompt-input-v2"]')
const input = composer.locator('[data-component="prompt-input"]')
const control = composer.getByRole("button", { name: "Choose model variant" })
@@ -49,7 +49,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
await installSseTransport(page, { server: serverB })
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
const transport = await installSseTransport(page, {
server: serverA,
retry: 20,
})
@@ -82,18 +82,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
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: [],
},
id: "evt_permission_background_a",
created: 1700000001000,
type: "permission.asked",
location: { directory: directoryA },
data: {
id: "permission-background-a",
sessionID: sessionA.id,
action: "bash",
resources: ["git status"],
metadata: {},
save: [],
},
})
@@ -110,18 +109,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
])
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: [],
},
id: "evt_permission_background_a_child",
created: 1700000002000,
type: "permission.asked",
location: { directory: directoryA },
data: {
id: "permission-background-a-child",
sessionID: childSessionA.id,
action: "bash",
resources: ["git diff"],
metadata: {},
save: [],
},
})
@@ -226,38 +224,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
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 (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 === "/api/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" })
if (url.pathname === "/api/location") return json(route, { directory })
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
@@ -57,47 +57,33 @@ async function mockServers(page: Page) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route, url.pathname === "/api/event")
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (url.pathname === "/project" || url.pathname === "/project/current") {
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/model/default")
return json(route, { location: { directory: current.directory }, data: null })
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
const project = {
id: current.projectID,
worktree: current.directory,
canonical: current.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/project" ? [project] : project)
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
}
if (url.pathname === "/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/api/path")
return json(route, {
state: current.directory,
config: current.directory,
worktree: current.directory,
directory: current.directory,
home: current.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
@@ -116,10 +102,10 @@ function json(route: Route, body: unknown, status = 200) {
})
}
function sse(route: Route, current: boolean) {
function sse(route: Route) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
})
}
@@ -7,6 +7,7 @@ const directory = "C:/OpenCode/ReviewImageFlashRegression"
const sessionID = "ses_review_image_flash_regression"
const title = "Review image flash regression"
const imageFile = "assets/preview.png"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("clicking an image file in the v2 review pane does not blank the panel", async ({ page }) => {
await openReview(page)
@@ -109,30 +110,16 @@ async function openReview(page: Page) {
pageMessages: () => ({
items: [
{
info: {
id: "msg_review_image_flash_regression",
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model: { providerID: "opencode", modelID: "test" },
},
parts: [
{
id: "prt_review_image_flash_regression",
sessionID,
messageID: "msg_review_image_flash_regression",
type: "text",
text: "Review this change.",
},
],
id: "msg_review_image_flash_regression",
type: "user",
time: { created: 1700000000000 },
text: "Review this change.",
},
],
}),
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectAppVisible(page.locator('#review-panel [data-component="session-review-v2"]'))
@@ -6,6 +6,7 @@ import { expectAppVisible, expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/ReviewLineCommentRegression"
const sessionID = "ses_review_line_comment_regression"
const title = "Review line comment regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.beforeEach(async ({ page }) => {
await openReview(page)
@@ -88,7 +89,6 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
async function openReview(page: Page) {
await page.setViewportSize({ width: 700, height: 900 })
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: "proj_review_line_comment_regression",
@@ -123,30 +123,16 @@ async function openReview(page: Page) {
pageMessages: () => ({
items: [
{
info: {
id: "msg_review_line_comment_regression",
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model: { providerID: "opencode", modelID: "test" },
},
parts: [
{
id: "prt_review_line_comment_regression",
sessionID,
messageID: "msg_review_line_comment_regression",
type: "text",
text: "Review this change.",
},
],
id: "msg_review_line_comment_regression",
type: "user",
time: { created: 1700000000000 },
text: "Review this change.",
},
],
}),
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const changes = page.getByRole("tab", { name: "Changes" })
const diffResponse = page.waitForResponse(
@@ -14,7 +14,6 @@ test.use({ viewport: { width: 1440, height: 900 } })
test("opens and searches project files inline", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = []
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -54,7 +54,6 @@ async function switchSession(page: Page, title: string) {
async function setup(page: Page) {
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -6,6 +6,7 @@ const directory = "C:/OpenCode/ReviewTerminalStacked"
const projectID = "proj_review_terminal_stacked"
const sessionID = "ses_review_terminal_stacked"
const title = "Review terminal stacked"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const branchDiffs = [
fileDiff(".github/actions/setup-bun/action.yml", 7),
...Array.from({ length: 2_739 }, (_, index) =>
@@ -21,7 +22,6 @@ test("keeps the review tree and terminal sized when both panels are open", async
test.setTimeout(120_000)
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -130,8 +130,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
)
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionReady(page, { server, sessionID, title })
await expect(page.locator("#review-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
@@ -16,8 +16,7 @@ test("shows loaded sessions before the directory path request resolves", async (
const pathBlocked = new Promise<void>((resolve) => {
releasePath = resolve
})
await page.route("**/api/path?*", async (route) => {
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
await page.route("**/api/location*", async (route) => {
await pathBlocked
return route.fallback()
})
@@ -8,6 +8,7 @@ const directory = "C:/OpenCode/RequestDocks"
const projectID = "proj_request_docks"
const sessionID = "ses_request_docks"
const title = "Request dock regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("shows a pending question dock", async ({ page }) => {
await mockServer(page, {
@@ -34,7 +35,7 @@ test("shows a pending question dock", async ({ page }) => {
],
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
@@ -92,7 +93,7 @@ test("shows a pending permission dock", async ({ page }) => {
],
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
@@ -111,11 +112,11 @@ test("shows a pending permission dock", async ({ page }) => {
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"}`,
server,
retry: 20,
})
await mockServer(page, { forms: [] })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await transport.waitForConnection()
await expectSessionTitle(page, title)
@@ -138,26 +139,26 @@ test("restores the draft caret before typing after a request dock closes", async
)
.toBe(cursor)
await transport.send({
directory,
payload: {
type: "form.created",
properties: {
form: {
id: "frm_question_caret",
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } },
fields: [
{
key: "q0",
type: "string",
title: "Continue",
description: "Continue?",
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
custom: true,
},
],
},
id: "evt_form_created",
created: 1700000001000,
type: "form.created",
location: { directory },
data: {
form: {
id: "frm_question_caret",
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } },
fields: [
{
key: "q0",
type: "string",
title: "Continue",
description: "Continue?",
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
custom: true,
},
],
},
},
})
@@ -166,11 +167,11 @@ test("restores the draft caret before typing after a request dock closes", async
await expect(editor).toHaveCount(0)
await transport.send({
directory,
payload: {
type: "form.cancelled",
properties: { sessionID, id: "frm_question_caret" },
},
id: "evt_form_cancelled",
created: 1700000002000,
type: "form.cancelled",
location: { directory },
data: { sessionID, id: "frm_question_caret" },
})
await expect(question).toHaveCount(0)
await expect(editor).toBeVisible()
@@ -183,13 +184,11 @@ async function mockServer(
page: Page,
requests: {
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
forms?: unknown[] | (() => unknown[])
sessionStatus?: Record<string, unknown>
},
) {
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -229,7 +228,6 @@ async function mockServer(
],
pageMessages: () => ({ items: [] }),
permissions: requests.permissions,
questions: requests.questions,
forms: requests.forms,
sessionStatus: requests.sessionStatus,
})
@@ -1,4 +1,5 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
@@ -8,14 +9,11 @@ const sessionID = "ses_timeline_state_regression"
const userMessageID = "msg_user_regression"
const assistantMessageID = "msg_assistant_regression"
const editPartID = "prt_0001_edit"
const textPartID = "prt_9999_text"
const textPartID = `${assistantMessageID}:text:0`
const title = "Timeline collapse state regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
type EventPayload = OpenCodeEvent
declare global {
interface Window {
@@ -27,25 +25,11 @@ declare global {
}
const userMessage = {
info: {
id: userMessageID,
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [
{
id: "prt_user_text",
sessionID,
messageID: userMessageID,
type: "text",
text: "Please edit the file.",
},
],
}
id: userMessageID,
type: "user",
time: { created: 1700000000000 },
text: "Please edit the file.",
} satisfies SessionMessageInfo
const editPart = {
id: editPartID,
@@ -74,31 +58,19 @@ const editPart = {
}
const streamedTextPart = {
id: textPartID,
sessionID,
messageID: assistantMessageID,
type: "text",
text: "Streaming added a later assistant text part.",
}
const assistantMessage = {
info: {
id: assistantMessageID,
sessionID,
role: "assistant",
time: { created: 1700000001000 },
parentID: userMessageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
},
parts: [editPart],
}
id: assistantMessageID,
type: "assistant",
time: { created: 1700000001000 },
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
agent: "build",
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
content: [toolContent(editPart)],
} satisfies SessionMessageInfo
test.describe("regression: session timeline local row state", () => {
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
@@ -106,7 +78,7 @@ test.describe("regression: session timeline local row state", () => {
await mockServer(page, events)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(sessionHref())
await expectSessionTitle(page, title)
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
@@ -119,15 +91,11 @@ test.describe("regression: session timeline local row state", () => {
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
await expectExpanded(wrapper, false)
events.push({
directory,
payload: {
type: "message.part.updated",
properties: { part: streamedTextPart },
},
})
events.push(...textEvents())
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({
timeout: 10_000,
})
expect(await readToolState(page)).toEqual({
expanded: false,
@@ -136,13 +104,13 @@ test.describe("regression: session timeline local row state", () => {
})
})
test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
test("does not remount an edit diff when a sibling part arrives", async ({ page }) => {
const events: EventPayload[] = []
await installDiffProbe(page)
await mockServer(page, events)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(sessionHref())
await expectSessionTitle(page, title)
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
@@ -151,15 +119,11 @@ test.describe("regression: session timeline local row state", () => {
await expectAppVisible(file)
await markDiffProbe(page)
events.push({
directory,
payload: {
type: "message.part.updated",
properties: { part: streamedTextPart },
},
})
events.push(...textEvents())
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({
timeout: 10_000,
})
const siblingProbe = await readDiffProbe(page)
expect(siblingProbe).toEqual({
fileMarker: "before",
@@ -169,27 +133,6 @@ test.describe("regression: session timeline local row state", () => {
shadowRoots: 0,
toolMarker: "before",
})
await markDiffProbe(page)
events.push({
directory,
payload: {
type: "message.part.updated",
properties: { part: editPartWithAdditions(2) },
},
})
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
{ timeout: 10_000 },
)
expect(await readDiffProbe(page)).toEqual({
fileMarker: "before",
frameMarker: "before",
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
rowMarker: "before",
shadowRoots: 0,
toolMarker: "before",
})
})
test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => {
@@ -216,10 +159,10 @@ test.describe("regression: session timeline local row state", () => {
},
},
}
await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }])
await mockServer(page, events, [userMessage, { ...assistantMessage, content: [toolContent(part)] }])
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(sessionHref())
await expectSessionTitle(page, title)
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
@@ -293,7 +236,7 @@ async function readToolState(page: Page) {
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
}),
textPartID,
`${assistantMessageID}:text:0`,
)
}
@@ -355,22 +298,103 @@ async function readDiffProbe(page: Page) {
})
}
function editPartWithAdditions(additions: number) {
function toolContent(part: typeof editPart): SessionMessageAssistant["content"][number] {
return {
...editPart,
type: "tool",
id: part.callID,
name: part.tool,
time: { created: part.state.time.start, ran: part.state.time.start, completed: part.state.time.end },
state: {
...editPart.state,
metadata: {
...editPart.state.metadata,
filediff: {
...editPart.state.metadata.filediff,
additions,
},
},
status: "completed",
input: part.state.input,
content: [{ type: "text", text: part.state.output }],
metadata: part.state.metadata as Record<string, JsonValue>,
},
}
}
let eventSequence = -1
function textEvents(): OpenCodeEvent[] {
return [
eventValue("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, 1),
eventValue(
"session.text.ended",
{
sessionID,
assistantMessageID,
ordinal: 0,
text: streamedTextPart.text,
},
1,
),
]
}
function toolEvents(part: typeof editPart): OpenCodeEvent[] {
return [
eventValue(
"session.tool.input.started",
{
sessionID,
assistantMessageID,
id: part.callID,
name: part.tool,
},
1,
),
eventValue(
"session.tool.input.ended",
{
sessionID,
assistantMessageID,
id: part.callID,
text: JSON.stringify(part.state.input),
},
1,
),
eventValue(
"session.tool.called",
{
sessionID,
assistantMessageID,
id: part.callID,
input: part.state.input,
executed: true,
},
1,
),
eventValue(
"session.tool.success",
{
sessionID,
assistantMessageID,
id: part.callID,
content: [{ type: "text", text: part.state.output }],
metadata: part.state.metadata as Record<string, JsonValue>,
executed: true,
},
2,
),
]
}
function eventValue<Type extends OpenCodeEvent["type"]>(
type: Type,
data: Extract<OpenCodeEvent, { type: Type }>["data"],
version: 1 | 2,
): Extract<OpenCodeEvent, { type: Type }> {
eventSequence++
return {
id: `evt_collapse_${eventSequence}`,
created: 1700000002000 + eventSequence,
type,
data,
location: { directory },
durable: { aggregateID: sessionID, seq: eventSequence, version },
} as unknown as Extract<OpenCodeEvent, { type: Type }>
}
function readExpanded(element: Element) {
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
const aria = trigger?.getAttribute("aria-expanded")
@@ -385,7 +409,12 @@ function readExpanded(element: Element) {
return !!content && content.getBoundingClientRect().height > 0
}
async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) {
async function mockServer(
page: Page,
events: EventPayload[],
messages: SessionMessageInfo[] = [userMessage, assistantMessage],
) {
eventSequence = -1
await mockOpenCodeServer(page, {
directory,
project: project(),
@@ -437,3 +466,8 @@ function provider() {
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
function sessionHref() {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
return `/server/${base64Encode(server)}/session/${sessionID}`
}
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
import {
@@ -17,11 +18,6 @@ const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "ma
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"]
const followingTextID = `${id("msg_assistant", 10)}:text:0`
type Message = {
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
parts: Record<string, unknown>[]
}
const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)]
test.describe("regression: session timeline context group resize", () => {
@@ -30,7 +26,7 @@ test.describe("regression: session timeline context group resize", () => {
await mockServer(page)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(sessionHref())
await expectSessionTitle(page, title)
await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first())
await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first())
@@ -45,7 +41,7 @@ test.describe("regression: session timeline context group resize", () => {
})
test("paints a stable exploring to explored transition", async ({ page }) => {
const events: { directory: string; payload: Record<string, unknown> }[] = []
const events: OpenCodeEvent[] = []
await page.setViewportSize({ width: 1400, height: 900 })
await mockServer(page, events, [
...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(),
@@ -53,7 +49,7 @@ test.describe("regression: session timeline context group resize", () => {
])
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(sessionHref())
await expectSessionTitle(page, title)
const devtools = await page.context().newCDPSession(page)
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
@@ -75,25 +71,21 @@ test.describe("regression: session timeline context group resize", () => {
})
await startVisualProbe(page, regions)
for (const [index, delay] of [120, 350, 80, 500].entries()) {
events.push({
directory,
payload: {
type: "message.part.updated",
properties: {
part: contextTool(
contextIDs[index]!,
id("msg_assistant", 10),
["read", "glob", "grep", "list"][index]!,
[
{ filePath: "src/recent-a.ts" },
{ path: directory, pattern: "**/*.ts" },
{ path: directory, pattern: "Explored" },
{ path: "src" },
][index]!,
),
},
},
})
events.push(
...toolEvents(
contextTool(
contextIDs[index]!,
id("msg_assistant", 10),
["read", "glob", "grep", "list"][index]!,
[
{ filePath: "src/recent-a.ts" },
{ path: directory, pattern: "**/*.ts" },
{ path: directory, pattern: "Explored" },
{ path: "src" },
][index]!,
),
),
)
await page.waitForTimeout(delay)
}
@@ -211,74 +203,51 @@ async function sampleExpansion(page: Page) {
)
}
function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] {
function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): SessionMessageInfo[] {
const userID = id("msg_user", index)
const assistantID = id("msg_assistant", index)
const content: SessionMessageAssistant["content"] = target
? [
toolContent(
contextTool(
contextIDs[0]!,
assistantID,
"read",
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
status,
),
),
toolContent(contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status)),
toolContent(
contextTool(
contextIDs[2]!,
assistantID,
"grep",
{ path: directory, pattern: "Explored", include: "*.ts" },
status,
),
),
toolContent(contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status)),
{ type: "text", text: "This assistant text is immediately after the explored context group." },
]
: [{ type: "text", text: `Assistant filler ${index}. ${"filler ".repeat(60)}` }]
return [
{
info: {
id: userID,
sessionID,
role: "user",
time: { created: 1700000000000 + index * 10_000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
id: userID,
type: "user",
time: { created: 1700000000000 + index * 10_000 },
text: `User message ${index}`,
},
{
info: {
id: assistantID,
sessionID,
role: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
parentID: userID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
finish: "stop",
},
parts: target
? [
contextTool(
contextIDs[0]!,
assistantID,
"read",
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
status,
),
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status),
contextTool(
contextIDs[2]!,
assistantID,
"grep",
{ path: directory, pattern: "Explored", include: "*.ts" },
status,
),
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
{
id: followingTextID,
sessionID,
messageID: assistantID,
type: "text",
text: "This assistant text is immediately after the explored context group.",
},
]
: [
{
id: id("prt_text", index),
sessionID,
messageID: assistantID,
type: "text",
text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
},
],
id: assistantID,
type: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
agent: "build",
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
finish: "stop",
content,
},
]
}
@@ -308,11 +277,76 @@ function contextTool(
}
}
async function mockServer(
page: Page,
events: { directory: string; payload: Record<string, unknown> }[] = [],
fixtureMessages = messages,
) {
type ContextTool = ReturnType<typeof contextTool>
function toolContent(part: ContextTool): SessionMessageAssistant["content"][number] {
const base = {
type: "tool" as const,
id: part.callID,
name: part.tool,
time: {
created: part.state.time.start,
ran: part.state.time.start,
...(part.state.status === "completed" ? { completed: part.state.time.end } : {}),
},
}
if (part.state.status === "running")
return {
...base,
state: {
status: "running",
input: part.state.input as Record<string, JsonValue>,
metadata: part.state.metadata as Record<string, JsonValue>,
},
}
return {
...base,
state: {
status: "completed",
input: part.state.input as Record<string, JsonValue>,
content: [{ type: "text", text: part.state.output }],
metadata: part.state.metadata as Record<string, JsonValue>,
},
}
}
let eventSequence = -1
function toolEvents(part: ContextTool): OpenCodeEvent[] {
return [
eventValue(
"session.tool.success",
{
sessionID,
assistantMessageID: part.messageID,
id: part.callID,
content: [{ type: "text", text: part.state.output }],
metadata: part.state.metadata,
executed: true,
},
2,
),
]
}
function eventValue<Type extends OpenCodeEvent["type"]>(
type: Type,
data: Extract<OpenCodeEvent, { type: Type }>["data"],
version: 1 | 2,
): Extract<OpenCodeEvent, { type: Type }> {
eventSequence++
return {
id: `evt_context_resize_${eventSequence}`,
created: 1700000002000 + eventSequence,
type,
data,
location: { directory },
durable: { aggregateID: sessionID, seq: eventSequence, version },
} as unknown as Extract<OpenCodeEvent, { type: Type }>
}
async function mockServer(page: Page, events: OpenCodeEvent[] = [], fixtureMessages = messages) {
eventSequence = -1
await mockOpenCodeServer(page, {
directory,
project: project(),
@@ -372,3 +406,8 @@ function provider() {
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
function sessionHref() {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
return `/server/${base64Encode(server)}/session/${sessionID}`
}
@@ -1,30 +1,5 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
partUpdated,
setupTimeline,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("updates edit diagnostics without resetting manual collapse state", async ({ page }) => {
const editID = "prt_diagnostics_edit"
const base = editPart(editID, [])
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([base])],
settings: { editToolPartsExpanded: true },
})
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.send(
partUpdated(editPart(editID, [diagnostic("First failure", 2), diagnostic("Second failure", 4)])),
300,
)
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.send(partUpdated(editPart(editID, [])), 300)
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
const patchID = "prt_nested_patch"
@@ -68,25 +43,6 @@ function patchFile(filePath: string, type: "add" | "update" | "delete") {
}
}
function editPart(id: string, diagnostics: Record<string, unknown>[]) {
return toolPart(
id,
"edit",
"completed",
{ filePath: "src/edit.ts" },
{
metadata: {
filediff: { file: "src/edit.ts", additions: 1, deletions: 1, before: source(false), after: source(true) },
diagnostics,
},
},
)
}
function diagnostic(message: string, line: number) {
return { message, severity: 1, range: { start: { line, character: 0 }, end: { line, character: 2 } } }
}
function source(changed: boolean) {
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
"",
@@ -1,4 +1,5 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import type { SessionMessageAssistant } from "@opencode-ai/client/promise"
import { expect, test, type Page } from "@playwright/test"
import {
assistantMessage,
@@ -17,6 +18,7 @@ import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const messagePageSize = 200
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
return [
@@ -29,19 +31,19 @@ const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
}),
]
}).flat()
const assistants = messages.filter((message) => message.info.role === "assistant")
const assistants = messages.filter((message): message is SessionMessageAssistant => message.type === "assistant")
const lastAssistant = assistants.at(-1)!
const lastPartID = `${assistants.at(-1)!.info.id}:text:0`
const userPartID = `${messages.at(-2)!.info.id}:text:0`
const lastPartID = `${assistants.at(-1)!.id}:text:0`
const userPartID = `${messages.at(-2)!.id}:text:0`
const completed = {
...lastAssistant.info,
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
...lastAssistant,
time: { ...lastAssistant.time, completed: lastAssistant.time.created + 15_000 },
}
const scenarios = [
{ name: "completion", info: completed, idleFirst: false, interrupted: false },
{
name: "interruption",
info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
info: { ...completed, error: { type: "MessageAbortedError", message: "Stopped" } },
idleFirst: true,
interrupted: true,
},
@@ -56,12 +58,11 @@ for (const scenario of scenarios) {
const roots: { sessionID: string; messageID: string }[] = []
const sequence: string[] = []
const history = Promise.withResolvers<void>()
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
const transport = await installSseTransport(page, {
server,
retry: 20,
})
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: project(),
provider: {
@@ -94,15 +95,15 @@ for (const scenario of scenarios) {
},
message: (requestedSessionID, messageID) => {
if (requestedSessionID !== sessionID) return
return messages.find((item) => item.info.id === messageID)
return messages.find((item) => item.id === messageID)
},
pageMessages: (_, limit, before) => {
pages.push({ before, limit })
const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
const start = Math.max(0, end - limit)
return {
items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined,
cursor: start > 0 ? messages[start]!.id : undefined,
}
},
})
@@ -152,7 +153,7 @@ for (const scenario of scenarios) {
requestAnimationFrame(() => setTimeout(sample, 0))
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await transport.waitForConnection()
await expectSessionTitle(page, title)
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
@@ -169,7 +170,7 @@ for (const scenario of scenarios) {
expect(sequence.slice(0, 3)).toEqual([
"messages:start:latest",
"messages:end:latest",
`messages:start:${messages.at(-messagePageSize)!.info.id}`,
`messages:start:${messages.at(-messagePageSize)!.id}`,
])
await page.evaluate(() => {
;(
@@ -187,7 +188,7 @@ for (const scenario of scenarios) {
await waitForProbeSamples(page, beforeHistory)
expect(pages).toEqual([
{ before: undefined, limit: messagePageSize },
{ before: messages.at(-messagePageSize)!.info.id, limit: messagePageSize },
{ before: messages.at(-messagePageSize)!.id, limit: messagePageSize },
])
expect(roots).toEqual([])
@@ -5,6 +5,7 @@ import {
messageUpdated,
partUpdated,
reasoningPart,
renderedPartID,
setupTimeline,
shell,
status,
@@ -65,7 +66,7 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
await timeline.send(status("idle"), 300)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
@@ -95,16 +96,16 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
await timeline.send(status("retry"), 180)
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(status("busy", 2), 180)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
await timeline.send(status("idle"), 350)
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-part-id="prt_recovered"]')).toContainText("Recovered response")
await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText(
"Recovered response",
)
})
function lines(count: number) {
@@ -39,7 +39,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
ownerWarnings.push(message.text())
})
await setupTimeline(page, {
currentMessages: [
sessionMessages: [
user,
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
assistant(true),
@@ -74,7 +74,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, { currentMessages: [user, assistant(false, true)] })
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
const card = page.locator('[data-component="task-tool-card"]')
await expect(card).toBeVisible()
await expect(card).toContainText("Inspect code")
@@ -92,14 +92,14 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
})
test("waits for completion before labeling requested background work", async ({ page }) => {
await setupTimeline(page, { currentMessages: [user, assistant(false, true, undefined, true)] })
await setupTimeline(page, { sessionMessages: [user, assistant(false, true, undefined, true)] })
await expect(page.locator('[data-component="task-tool-card"]')).not.toContainText("(background)")
})
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
const childID = "ses_running_child"
await setupTimeline(page, {
currentMessages: [user, assistant(false, true, childID)],
sessionMessages: [user, assistant(false, true, childID)],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
@@ -113,7 +113,7 @@ test("navigates from a running subagent card and hides background controls in th
test("shows a badge for active background work", async ({ page }) => {
const childID = "ses_background_child"
await setupTimeline(page, {
currentMessages: [user, assistant(true)],
sessionMessages: [user, assistant(true)],
sessions: [session(), session({ id: childID, parentID: sessionID })],
sessionStatus: { [childID]: { type: "busy" } },
})
@@ -125,7 +125,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
const backgroundID = "ses_background_existing"
const blockingID = "ses_background_blocking"
const timeline = await setupTimeline(page, {
currentMessages: [
sessionMessages: [
user,
{
id: "msg_backgrounded",
@@ -203,7 +203,12 @@ test("separates blocking and already-backgrounded work into two rows", async ({
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
).toHaveAttribute("data-active", "true")
await timeline.send(event("session.status", { sessionID: backgroundID, status: { type: "idle" } }))
await timeline.transport.send({
id: "evt_background_succeeded",
created: Date.now(),
type: "session.execution.succeeded",
data: { sessionID: backgroundID },
} as never)
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
await expect(backgroundCard).toContainText("Background task (background)")
})
@@ -71,33 +71,20 @@ test.describe("session timeline projection", () => {
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
const firstUser = userMessage(
[
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
id: "prt_comment",
synthetic: true,
metadata: {
opencodeComment: {
path: "src/a.ts",
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
comment: "Keep this stable",
},
},
}),
userText("Keep this stable", { id: "prt_comment" }),
userText("Continue after the comment", { id: "prt_visible_user" }),
],
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
)
const aborted = assistantMessage([{ id: "prt_before_abort", type: "text", text: "Before interruption" }], {
id: "msg_1001_assistant_aborted",
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
error: { type: "MessageAbortedError", message: "Stopped" },
})
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
id: "msg_1002_assistant_failed",
error: {
name: "APIError",
data: {
message: JSON.stringify({ error: { type: "provider_error", message: "Visible provider failure" } }),
isRetryable: false,
},
type: "APIError",
message: "Visible provider failure",
},
created: 1700000003000,
})
@@ -122,48 +109,11 @@ test.describe("session timeline projection", () => {
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
})
test("renders legacy synthetic comments as ordinary V2 user text", async ({ page }) => {
const user = userMessage(
[
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
id: "prt_comment_only",
synthetic: true,
metadata: {
opencodeComment: {
path: "src/a.ts",
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
comment: "Keep this stable",
},
},
}),
userText("Continue after the comment", { id: "prt_comment_visible" }),
],
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
)
const nextUser = userMessage(undefined, { id: "msg_2000_diff_next_user", created: 1700000010000 })
const nextAssistant = assistantMessage([], {
id: "msg_2001_diff_next_assistant",
parentID: "msg_2000_diff_next_user",
created: 1700000011000,
})
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
await expect(
page.getByText(
"The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable Continue after the comment",
{ exact: true },
),
).toBeVisible()
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
})
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
const user = userMessage()
const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
id: "msg_1001_before",
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
error: { type: "MessageAbortedError", message: "Stopped" },
})
const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
id: "msg_1002_after",
@@ -4,6 +4,7 @@ import {
completedAssistantInfo,
messageUpdated,
partUpdated,
renderedPartID,
setupTimeline,
shell,
status,
@@ -36,8 +37,10 @@ test("reducer-hardening: converges when idle arrives before final part and messa
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 100)
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 250)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("Final after early idle")
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
"Final after early idle",
)
})
@@ -1,77 +0,0 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
partUpdated,
setupTimeline,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("updates expanded web search links without resetting expansion", async ({ page }) => {
const searchID = "prt_websearch_mutation"
const input = { query: "timeline stability" }
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([toolPart(searchID, "websearch", "completed", input, { output: "https://example.com/one" })]),
],
})
const wrapper = page.locator(`[data-timeline-part-id="${searchID}"]`)
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await timeline.send(
partUpdated(
toolPart(searchID, "websearch", "completed", input, {
output: "https://example.com/one\nhttps://example.com/two",
}),
),
300,
)
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(wrapper.locator('a[href="https://example.com/two"]')).toBeVisible()
})
test("preserves an expanded tool error card across duplicate delivery", async ({ page }) => {
const toolID = "prt_duplicate_error"
const failed = toolPart(toolID, "bash", "error", { command: "exit 1" }, { error: "Command failed visibly" })
const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([failed])] })
const wrapper = page.locator(`[data-timeline-part-id="${toolID}"]`)
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await timeline.send(partUpdated(failed), 150)
await timeline.send(partUpdated(failed), 250)
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(wrapper).toContainText("Command failed visibly")
})
test("renders multiple question answers and preserves open state on answer updates", async ({ page }) => {
const questionID = "prt_multi_question"
const input = {
questions: [
{ header: "First", question: "First choice?", options: [] },
{ header: "Second", question: "Second choice?", options: [], multiple: true },
],
}
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["A"], ["B", "C"]] } }),
]),
],
})
const wrapper = page.locator(`[data-timeline-part-id="${questionID}"]`)
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await timeline.send(
partUpdated(
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Updated"], ["B", "C"]] } }),
),
300,
)
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(wrapper).toContainText("Updated")
await expect(wrapper).toContainText("B, C")
})
@@ -1,43 +1,44 @@
import { expect, test, type Page } from "@playwright/test"
import { partUpdated, setupTimeline, textPart } from "../performance/timeline-stability/fixture"
import { partUpdated, renderedPartID, setupTimeline, textPart } from "../performance/timeline-stability/fixture"
test("keeps one connection open while delivering multiple events", async ({ page }) => {
const timeline = await setupTimeline(page)
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event")))
const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event")))
const first = (await timeline.transport.burst(partUpdated(textPart("prt_transport_first", "first event")))).at(-1)!
const second = (await timeline.transport.burst(partUpdated(textPart("prt_transport_second", "second event")))).at(-1)!
await timeline.waitForPart("prt_transport_first")
await timeline.waitForPart("prt_transport_second")
expect(first.connectionID).toBe(second.connectionID)
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
expect(await timeline.transport.acknowledgements()).toHaveLength(4)
})
test("delivers a burst from one stream chunk", async ({ page }) => {
const timeline = await setupTimeline(page)
const acknowledgements = await timeline.transport.burst([
partUpdated(textPart("prt_transport_burst_a", "burst a")),
partUpdated(textPart("prt_transport_burst_b", "burst b")),
...partUpdated(textPart("prt_transport_burst_a", "burst a")),
...partUpdated(textPart("prt_transport_burst_b", "burst b")),
])
await timeline.waitForPart("prt_transport_burst_a")
await timeline.waitForPart("prt_transport_burst_b")
expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1])
expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2)
expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1, 1, 1])
expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(4)
})
test("parses split JSON and a split multibyte code point", async ({ page }) => {
const timeline = await setupTimeline(page)
const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))
const [started, payload] = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))
await timeline.transport.send(started!)
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`)
const snowman = new TextEncoder().encode("\u2603")[0]!
const multibyte = encoded.indexOf(snowman)
const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2])
const acknowledgement = await timeline.transport.split(payload!, [9, multibyte + 1, multibyte + 2])
await timeline.waitForPart("prt_transport_split")
await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText(
await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_transport_split")}"]`)).toContainText(
"split snowman \u2603\u2603\u2603",
)
expect(acknowledgement.chunkCount).toBe(4)
@@ -46,12 +47,11 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => {
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
const timeline = await setupTimeline(page)
const partID = "prt_transport_heartbeat_sentinel"
const sentinel = await timeline.transport.send(partUpdated(textPart(partID, "heartbeat sentinel")))
const sentinel = (await timeline.transport.burst(partUpdated(textPart(partID, "heartbeat sentinel")))).at(-1)!
await timeline.waitForPart(partID)
await expect(page.locator(`[data-timeline-part-id="${partID}"] [data-component="markdown"]`)).toHaveAttribute(
"data-markdown-ready",
"",
)
await expect(
page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"] [data-component="markdown"]`),
).toHaveAttribute("data-markdown-ready", "")
const before = await timelineRows(page)
const heartbeat = await timeline.transport.heartbeat()
@@ -66,7 +66,7 @@ test("reconnects after a clean close", async ({ page }) => {
await timeline.transport.close()
const second = await timeline.transport.waitForConnection({ after: first.id })
await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close")))
await timeline.transport.burst(partUpdated(textPart("prt_transport_close", "after close")))
await timeline.waitForPart("prt_transport_close")
expect(second.id).toBeGreaterThan(first.id)
@@ -79,7 +79,7 @@ test("reconnects after a stream error", async ({ page }) => {
await timeline.transport.error("contract failure")
const second = await timeline.transport.waitForConnection({ after: first.id })
await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error")))
await timeline.transport.burst(partUpdated(textPart("prt_transport_error", "after error")))
await timeline.waitForPart("prt_transport_error")
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
@@ -89,9 +89,13 @@ test("reconnects after a stream error", async ({ page }) => {
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7",
})
const events = partUpdated(textPart("prt_transport_id", "event with id"))
const first = (
await timeline.transport.burst(
events,
events.map((_, index) => (index === events.length - 1 ? { id: "timeline-event-7" } : {})),
)
).at(-1)!
await timeline.waitForPart("prt_transport_id")
await timeline.transport.error("retry with event id")
@@ -1,168 +0,0 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/TodoDockNavigation"
const projectID = "proj_todo_dock_navigation"
const sourceID = "ses_todo_dock_source"
const otherID = "ses_todo_dock_other"
const sourceTitle = "Todo dock animation"
const otherTitle = "Separate session"
const activeTodos = [
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
]
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
test.use({ viewport: { width: 1440, height: 900 } })
test("animates todo opening without replaying it across session tabs", async ({ page }) => {
test.setTimeout(90_000)
const events: EventPayload[] = []
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "todo-dock-navigation",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"claude-opus-4-6": {
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
limit: { context: 200_000 },
},
},
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
sessionStatus: () => sessionStatus,
todos: (sessionID) => todos[sessionID] ?? [],
})
await configurePage(page)
await page.goto(sessionHref(sourceID))
await expectSessionTitle(page, sourceTitle)
const dock = page.locator('[data-component="session-todo-dock"]')
await expect(dock).toHaveCount(0)
sessionStatus[sourceID] = { type: "busy" }
events.push(statusEvent(sourceID, "busy"))
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await page.waitForTimeout(700)
const opening = sampleDock(page, 1_000)
todos[sourceID] = activeTodos
events.push(todoEvent(sourceID, activeTodos))
await expect(dock).toBeVisible()
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
await switchSession(page, otherID, otherTitle)
await expect(dock).toHaveCount(0)
await switchSession(page, sourceID, sourceTitle)
await expect(dock).toHaveCount(0)
})
function session(id: string, title: string, created: number) {
return {
id,
slug: id,
projectID,
directory,
title,
version: "dev",
time: { created, updated: created },
}
}
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
return {
directory,
payload: { type: "session.status", properties: { sessionID, status: { type } } },
}
}
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
return {
directory,
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
}
}
async function configurePage(page: Page) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
await page.addInitScript(
({ directory, dirBase64, server, sessionIDs }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
)
},
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
)
}
function sessionHref(sessionID: string) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
return `/server/${base64Encode(server)}/session/${sessionID}`
}
async function switchSession(page: Page, sessionID: string, title: string) {
const href = sessionHref(sessionID)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
await expectSessionTitle(page, title)
}
function sampleDock(page: Page, duration: number) {
return page.evaluate(async (duration) => {
const samples: { present: boolean; height: number; opacity: number }[] = []
const start = performance.now()
while (performance.now() - start < duration) {
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
const clip = dock?.parentElement?.parentElement
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
samples.push({
present: !!dock,
height: clip?.getBoundingClientRect().height ?? 0,
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
})
await new Promise(requestAnimationFrame)
}
return samples
}, duration)
}
@@ -1,4 +1,5 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import { expect, test, type Page } from "@playwright/test"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -12,8 +13,6 @@ const childTitle = "Subagent child session"
// Child session pages derive their heading from the task part that spawned them.
const taskDescription = "Inspect child navigation"
type EventPayload = { directory: string; payload: Record<string, unknown> }
test.use({ viewport: { width: 1440, height: 900 } })
test("navigates to a subagent child session missing from the session list", async ({ page }) => {
@@ -27,7 +26,7 @@ test("navigates to a subagent child session missing from the session list", asyn
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
})
test("keeps the parent visible while child lineage resolves", async ({ page }) => {
test("keeps the parent visible while the child session resolves", async ({ page }) => {
await setup(page)
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
@@ -52,14 +51,18 @@ test("keeps the parent visible while child lineage resolves", async ({ page }) =
})
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
const events: EventPayload[] = []
const events: OpenCodeEvent[] = []
await setup(page, () => events.splice(0, 1))
await openChildFromParent(page)
await expectSessionTitle(page, taskDescription)
events.push({
directory,
payload: { type: "session.deleted", properties: { info: childSession() } },
id: "evt_session_deleted",
created: 1700000003000,
type: "session.deleted",
durable: { aggregateID: childID, seq: 1, version: 2 },
location: { directory },
data: { sessionID: childID },
})
await expect(page.getByText("This session cannot be found")).toBeVisible()
@@ -67,7 +70,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
})
async function setup(page: Page, events?: () => EventPayload[]) {
async function setup(page: Page, events?: () => OpenCodeEvent[]) {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -142,60 +145,36 @@ function childSession() {
return session(childID, childTitle, 1700000001000, { parentID })
}
function parentMessages() {
function parentMessages(): SessionMessageInfo[] {
const userID = "msg_user_0001"
const assistantID = "msg_assistant_0001"
return [
{
info: {
id: userID,
sessionID: parentID,
role: "user",
time: { created: 1700000000000 },
agent: "build",
model: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
parts: [
{
id: "prt_user_text_0001",
sessionID: parentID,
messageID: userID,
type: "text",
text: "Delegate work to a subagent",
},
],
id: userID,
type: "user",
time: { created: 1700000000000 },
text: "Delegate work to a subagent",
},
{
info: {
id: assistantID,
sessionID: parentID,
role: "assistant",
time: { created: 1700000001000, completed: 1700000002000 },
parentID: userID,
modelID: "claude-opus-4-6",
providerID: "opencode",
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
finish: "stop",
},
parts: [
id: assistantID,
type: "assistant",
time: { created: 1700000001000, completed: 1700000002000 },
model: { id: "claude-opus-4-6", providerID: "opencode" },
agent: "build",
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
finish: "stop",
content: [
{
id: "prt_tool_task_0001",
sessionID: parentID,
messageID: assistantID,
type: "tool",
callID: "call_task_0001",
tool: "task",
id: "call_task_0001",
name: "task",
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
state: {
status: "completed",
input: { description: taskDescription, subagent_type: "explore" },
output: "Subagent finished",
title: taskDescription,
content: [{ type: "text", text: "Subagent finished" }],
metadata: { sessionId: childID },
time: { start: 1700000001000, end: 1700000002000 },
},
},
],
@@ -85,8 +85,7 @@ async function mockServer(page: Page) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback()
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
return new Promise(() => {})
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
@@ -95,41 +94,29 @@ async function mockServer(page: Page) {
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
return json(route, { data: [], cursor: {} })
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 (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (url.pathname === "/project" || url.pathname === "/project/current") {
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/model/default")
return json(route, { location: { directory: sessionA.directory }, data: null })
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory: sessionA.directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
const project = {
id: sessionA.projectID,
worktree: sessionA.directory,
canonical: sessionA.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/project" ? [project] : project)
return json(
route,
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
)
}
if (url.pathname === "/path")
return json(route, {
state: sessionA.directory,
config: sessionA.directory,
worktree: sessionA.directory,
directory: sessionA.directory,
home: sessionA.directory,
})
if (url.pathname === "/api/path")
return json(route, {
state: sessionA.directory,
config: sessionA.directory,
worktree: sessionA.directory,
directory: sessionA.directory,
home: sessionA.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: sessionA.directory },
@@ -1,4 +1,4 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -8,12 +8,12 @@ const projectID = "proj_terminal_composer_focus"
const sessionID = "ses_terminal_composer_focus"
const ptyID = "pty_terminal_composer_focus"
const newPtyID = "pty_terminal_composer_focus_new"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test.use({ viewport: { width: 1440, height: 900 } })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -74,7 +74,7 @@ test.beforeEach(async ({ page }) => {
})
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
@@ -113,7 +113,7 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
})
await seedCachedTerminal(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`, { waitUntil: "commit" })
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
@@ -139,7 +139,7 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
await route.continue()
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
@@ -184,7 +184,7 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
)
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, "Terminal composer focus")
const composer = page.locator('[data-component="prompt-input"]')
@@ -211,12 +211,18 @@ function seedCachedTerminal(page: Page) {
}),
)
},
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
{ terminalKey: terminalStorageKey(), ptyID },
)
}
function terminalStorageKey() {
const dir = base64Encode(directory)
const head = dir.slice(0, 12).replace(/[^a-zA-Z0-9._-]/g, "-")
return `opencode.workspace.${head}.${checksum(dir) ?? "0"}.dat:workspace:terminal`
}
function ptyLocation() {
return { directory, project: { id: projectID, directory } }
return { directory, project: { id: projectID, directory, canonical: directory } }
}
function ptyInfo(id: string, title: string) {
@@ -6,6 +6,7 @@ const directory = "C:/OpenCode/HiddenTerminalRegression"
const projectID = "proj_hidden_terminal_regression"
const sessionID = "ses_hidden_terminal_regression"
const title = "Hidden terminal regression"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
@@ -91,7 +92,7 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
)
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await page.keyboard.press("Control+Backquote")
@@ -21,16 +21,29 @@ const words = [
"vector",
]
const serverKey = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverKey = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
type Message = { info: MessageInfo; parts: MessagePart[] }
type MessagePart = {
id: string
type: "text" | "reasoning" | "tool"
text?: string
time?: { start: number; end?: number }
callID?: string
tool?: string
state?: {
status: "completed"
input: Record<string, unknown>
output: string
title: unknown
metadata: Record<string, unknown>
time: { start: number; end: number }
}
}
function lorem(seed: number, length: number) {
let out = ""
@@ -48,54 +61,59 @@ function id(prefix: string, value: number) {
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
}
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo {
const messageID = id("msg_user", index)
return {
info: {
id: messageID,
sessionID,
role: "user",
time: { created: 1700000000000 + index * 10_000 },
summary: { diffs },
agent: "build",
model,
},
parts: [
{
id: id("prt_user_text", index),
sessionID,
messageID,
type: "text",
text: lorem(index, textLength),
},
],
id: messageID,
type: "user",
time: { created: 1700000000000 + index * 10_000 },
text: lorem(index, textLength),
metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined,
}
}
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
function assistantMessage(
_sessionID: string,
index: number,
_parentID: string,
parts: MessagePart[],
): SessionMessageInfo {
const messageID = id("msg_assistant", index)
return {
info: {
id: messageID,
sessionID,
role: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
parentID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
finish: "stop",
id: messageID,
type: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
agent: "build",
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
finish: "stop",
content: parts.map(messageContent),
}
}
function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] {
if (part.type === "text") return { type: "text", text: part.text ?? "" }
if (part.type === "reasoning")
return {
type: "reasoning",
text: part.text ?? "",
time: part.time
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
: undefined,
}
const state = part.state!
return {
type: "tool",
id: part.callID ?? part.id,
name: part.tool!,
time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
state: {
status: "completed",
input: state.input as Record<string, JsonValue>,
content: [{ type: "text", text: state.output }],
metadata: state.metadata as Record<string, JsonValue>,
},
parts: parts.map((part) => ({
...part,
sessionID,
messageID,
})),
}
}
@@ -180,7 +198,7 @@ function code(seed: number, lines: number) {
)
}
function turn(index: number): Message[] {
function turn(index: number): SessionMessageInfo[] {
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
const parts = [
@@ -219,7 +237,7 @@ function turn(index: number): Message[] {
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
: []),
]
return [user, assistantMessage(targetID, index, user.info.id, parts)]
return [user, assistantMessage(targetID, index, user.id, parts)]
}
const targetMessages = Array.from({ length: 101 }, (_, index) => turn(index)).flat()
@@ -227,22 +245,16 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
]).flat()
const messages: Record<string, Message[]> = { [sourceID]: sourceMessages, [targetID]: targetMessages }
const messages: Record<string, SessionMessageInfo[]> = { [sourceID]: sourceMessages, [targetID]: targetMessages }
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text?.trim()
if (part.type === "reasoning") return !!part.text?.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
}
function currentPartIDs(message: Message) {
function currentPartIDs(message: SessionMessageInfo) {
if (message.type === "user") return message.text.trim() ? [`${message.id}:text:0`] : []
if (message.type !== "assistant") return []
const ordinals = { text: 0, reasoning: 0 }
return message.parts.flatMap((part) => {
if (!renderable(part)) return []
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
return message.content.flatMap((part) => {
if (part.type === "text") return part.text.trim() ? [`${message.id}:text:${ordinals.text++}`] : []
if (part.type === "reasoning") return part.text.trim() ? [`${message.id}:reasoning:${ordinals.reasoning++}`] : []
if (part.type === "tool") return [part.id]
return []
})
}
@@ -295,12 +307,11 @@ export const fixture = {
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetMessageIDs: targetMessages.filter((message) => message.type === "user").map((message) => message.id),
targetPartIDs: targetMessages.flatMap(currentPartIDs),
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!
.callID,
expandedShellPartID: targetMessages
.flatMap((message) => (message.type === "assistant" ? message.content : []))
.flatMap((part) => (part.type === "tool" && part.name === "bash" ? [part.id] : []))[0],
},
}
@@ -309,12 +320,13 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
const end = before
? Math.max(
0,
messages.findIndex((message) => message.info.id === before),
messages.findIndex((message) => message.id === before),
)
: messages.length
const start = Math.max(0, end - limit)
return {
items: messages.slice(start, end),
cursor: start > 0 ? messages[start].info.id : undefined,
cursor: start > 0 ? messages[start].id : undefined,
}
}
import type { JsonValue, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
+10 -15
View File
@@ -33,7 +33,6 @@ test.describe("smoke: session timeline", () => {
test("keeps the visible message fixed while prepending history", async ({ page }) => {
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -44,7 +43,7 @@ test.describe("smoke: session timeline", () => {
})
await configureSmokePage(page, fixture.directory)
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
await waitForTimelineStable(page)
const scroller = timelineScroller(page)
await pointAtTimeline(page)
@@ -92,7 +91,6 @@ test.describe("smoke: session timeline", () => {
test("preserves the timeline gap above the composer", async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -101,7 +99,7 @@ test.describe("smoke: session timeline", () => {
})
await configureSmokePage(page, fixture.directory)
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
await waitForTimelineStable(page)
const scroller = timelineScroller(page)
await scroller.evaluate((element) => {
@@ -119,7 +117,6 @@ test.describe("smoke: session timeline", () => {
test("paints cached session tabs at the latest message", async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -143,11 +140,11 @@ test.describe("smoke: session timeline", () => {
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${fixture.targetID}`)
await expectSessionTitle(page, fixture.expected.targetTitle)
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
const destination = fixture.messages[fixture.targetID].map((message) => message.id)
const last = fixture.expected.targetMessageIDs.at(-1)!
await page.evaluate(
({ destination, last }) => {
@@ -262,7 +259,6 @@ test.describe("smoke: session timeline", () => {
test("paints a cold session tab at the latest message", async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -285,10 +281,10 @@ test.describe("smoke: session timeline", () => {
},
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${fixture.sourceID}`)
await expectSessionTitle(page, fixture.expected.sourceTitle)
const last = fixture.expected.targetMessageIDs.at(-1)!
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
const destination = fixture.messages[fixture.targetID].map((message) => message.id)
await page.evaluate(
({ destination, last }) => {
const ids = new Set(destination)
@@ -341,7 +337,6 @@ test.describe("smoke: session timeline", () => {
test("renders seeded timeline in order while paging through history", async ({ page }) => {
const errors = trackPageErrors(page)
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -351,9 +346,9 @@ test.describe("smoke: session timeline", () => {
await configureSmokePage(page, fixture.directory)
await selectHomeProject(page, fixture.project.name)
await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle)
await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
await expectSessionReady(page)
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
const expectedPartIDs = fixture.expected.targetPartIDs
const expectedMessageIDs = fixture.expected.targetMessageIDs
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
@@ -742,8 +737,8 @@ async function selectHomeProject(page: Page, projectName: string) {
await expect(page).toHaveURL(/\/$/)
}
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionId}`)
await expectSessionTitle(page, expectedTitle)
}
@@ -6,7 +6,6 @@ const directory = "C:/OpenCode/NewProject"
test("creates a session in a new project and selects its model", async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: "proj_model_selection_flow",
+3
View File
@@ -6,6 +6,9 @@ export function trackPageErrors(page: Page) {
if (message.type() === "error") errors.push(message.text())
})
page.on("pageerror", (error) => errors.push(error.stack ?? error.message))
page.on("response", (response) => {
if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`)
})
return errors
}
+89 -377
View File
@@ -1,37 +1,30 @@
import type { Page, Route } from "@playwright/test"
import type {
JsonValue,
PromptAgentAttachment,
PromptFileAttachment,
SessionMessageAssistant,
SessionMessageInfo,
SessionStructuredError,
} from "@opencode-ai/client/promise"
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
export interface MockServerConfig {
protocol?: "v1" | "v2"
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
onInstanceDispose?: () => void
directory: string
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
pageMessages: (
sessionId: string,
limit: number,
before?: string,
) => {
items: SessionMessageInfo[]
cursor?: string
}
vcsDiff?: unknown[]
messageDelay?: number
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
message?: (sessionID: string, messageID: string) => unknown
message?: (sessionID: string, messageID: string) => SessionMessageInfo | undefined
onMessage?: (input: { sessionID: string; messageID: string }) => void
events?: () => unknown[]
events?: () => OpenCodeEvent[]
eventRetry?: number
todos?: (sessionID: string) => unknown[]
permissions?: unknown[] | (() => unknown[])
questions?: unknown[] | (() => unknown[])
forms?: unknown[] | (() => unknown[])
fileList?: (path: string) => unknown | Promise<unknown>
fileContent?: (path: string) => unknown | Promise<unknown>
@@ -46,10 +39,11 @@ type MockStreamWindow = Window & {
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>()
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
let nextCursor = 0
await page.addInitScript(
({ port, retry }) => {
({ server, retry }) => {
const host = window as MockStreamWindow
if (host.__testSseTransport || host.__mockServerStream) return
const originalFetch = window.fetch.bind(window)
@@ -74,7 +68,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
const url = new URL(request.url)
if (url.port !== port || url.pathname !== "/api/event") return originalFetch(request)
if (url.origin !== server || url.pathname !== "/api/event") return originalFetch(request)
state.connections += 1
const id = state.connections
let ended = false
@@ -114,7 +108,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
},
{ port: process.env.PLAYWRIGHT_SERVER_PORT ?? "4096", retry: config.eventRetry },
{ server, retry: config.eventRetry },
)
if (config.events) {
@@ -125,10 +119,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (batch.length === 0) return
pump.busy = true
void page
.evaluate(
(payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads),
batch.map(currentEvent),
)
.evaluate((payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads), batch as unknown[])
.catch(() => {})
.finally(() => {
pump.busy = false
@@ -136,87 +127,33 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}, 50)
page.on("close", () => clearInterval(timer))
}
const staticRoutes: Record<string, unknown> = {
"/path": {
state: config.directory,
config: config.directory,
worktree: config.directory,
directory: config.directory,
home: "C:/OpenCode",
},
"/project": [config.project],
"/project/current": config.project,
"/agent": [{ name: "build", mode: "primary" }],
"/vcs": { branch: "main", default_branch: "main" },
"/session": config.sessions,
}
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const appPort = new URL(
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
).port
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
if (url.origin !== server && url.port !== appPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event" || path === "/api/event") {
if (path === "/api/event") {
const events = config.events?.()
return sse(
route,
path === "/api/event"
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
: [
...(path === "/global/event"
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
: []),
...(events ?? []),
],
[{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])],
config.eventRetry,
)
}
if (path === "/global/health")
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/provider") return json(route, providerConfig(config))
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
if (legacyAuth && route.request().method() === "PUT") {
config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
return json(route, true)
}
if (path === "/instance/dispose" && route.request().method() === "POST") {
config.onInstanceDispose?.()
return json(route, true)
}
if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
if (path === "/question")
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
if (path === "/session/status")
return json(
route,
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}),
)
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
if (path === "/file" && config.fileList)
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
if (path === "/file/content" && config.fileContent)
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
if (path === "/find/file" && config.findFiles)
return json(
route,
await config.findFiles({
query: url.searchParams.get("query") ?? "",
dirs: url.searchParams.get("dirs") ?? undefined,
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
}),
)
if (path === "/api/reference")
return json(route, {
location: {
directory: config.directory,
project: { id: (config.project as { id?: string }).id, directory: config.directory },
project: {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
},
},
data: [],
})
@@ -277,7 +214,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
])
}
if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
return json(route, {
id: (config.project as { id?: string }).id,
directory: config.directory,
canonical: config.directory,
})
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
if (worktree && route.request().method() === "GET")
return json(route, [
@@ -303,18 +244,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
currentPermission,
),
})
if (path === "/api/question/request")
return json(route, {
location: location(config),
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
})
if (path === "/api/form/request")
return json(route, {
location: location(config),
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
})
if (path === "/api/vcs")
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
return json(route, { location: location(config), data: { branch: { current: "main", default: "main" } } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
if (path === "/api/fs/list" && config.fileList)
@@ -352,10 +288,26 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
: entries,
})
}
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
if (path === "/api/shell" && route.request().method() === "GET")
return json(route, { location: location(config), data: [] })
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
if (path === "/api/session") {
if (route.request().method() === "POST") {
const payload = route.request().postDataJSON() as Record<string, unknown>
const created = currentSession(
{
id: "ses_mock_created",
projectID: (config.project as { id?: string }).id,
title: typeof payload.title === "string" ? payload.title : "New session",
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
},
config.directory,
)
config.sessions.push(created)
return json(route, { data: created })
}
if (route.request().method() !== "GET") return route.fallback()
const directory = url.searchParams.get("directory")
const parentID = url.searchParams.get("parentID")
const limit = Number(url.searchParams.get("limit") ?? 50)
@@ -365,7 +317,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const location = session.location as { directory?: string } | undefined
return !directory || location?.directory === directory || session.directory === directory
})
.filter((session) => parentID !== "null" || session.parentID === undefined)
.filter((session) => {
if (parentID === null) return true
if (parentID === "null") return session.parentID === undefined
return session.parentID === parentID
})
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
return (
@@ -375,7 +331,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
.includes(search)
)
})
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
const ordered = url.searchParams.get("order") === "asc" ? sessions : sessions.toReversed()
const data = ordered.slice(offset, offset + limit)
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
return json(route, {
@@ -398,9 +354,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
if (sessionForm && route.request().method() === "GET") {
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
@@ -416,12 +369,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST")
return json(route, true)
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
return json(route, true)
if (
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
/^\/api\/session\/[^/]+\/(rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
route.request().method() === "POST"
) {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
@@ -429,10 +378,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path])
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
if (currentSessionMatch) {
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
@@ -442,69 +387,37 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
}
const currentMessageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
if (currentMessageMatch) {
config.onMessage?.({ sessionID: currentMessageMatch[1]!, messageID: currentMessageMatch[2]! })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const message = config.message?.(currentMessageMatch[1]!, currentMessageMatch[2]!)
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
return json(route, { data: currentMessage(message) })
}
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
if (sessionMatch) return json(route, config.sessions.find((session) => session.id === sessionMatch[1]) ?? {})
const projectMatch = path.match(/^\/project\/([^/]+)$/)
if (projectMatch) return json(route, config.project)
const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
const messageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
if (messageMatch) {
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const message = config.message?.(messageMatch[1]!, messageMatch[2]!)
const message =
config.message?.(messageMatch[1]!, messageMatch[2]!) ??
config.pageMessages(messageMatch[1]!, Number.MAX_SAFE_INTEGER).items.find((item) => item.id === messageMatch[2])
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
return json(route, message)
return json(route, { data: message })
}
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
if (currentMessagesMatch) {
const token = url.searchParams.get("cursor") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
if (cursor) cursors.set(cursor, pageData.cursor!)
return json(route, {
data: pageData.items.map(currentMessage).reverse(),
cursor: { next: cursor },
})
}
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
const messagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const token = url.searchParams.get("before") ?? undefined
const token = url.searchParams.get("cursor") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 80), before)
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
if (!pageData.cursor) return json(route, pageData.items)
const cursor = `cursor_${++nextCursor}`
cursors.set(cursor, pageData.cursor)
return json(route, pageData.items, { "x-next-cursor": cursor })
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
if (cursor) cursors.set(cursor, pageData.cursor!)
return json(route, {
data: url.searchParams.get("order") === "asc" ? pageData.items : pageData.items.toReversed(),
cursor: { next: cursor },
})
}
if (url.port === targetPort && targetPort !== appPort) return json(route, {})
if (url.port === targetPort && targetPort !== appPort)
return json(route, { error: `Unhandled mock route: ${path}` }, undefined, 404)
return route.fallback()
})
}
@@ -522,13 +435,21 @@ function providerConfig(config: MockServerConfig) {
function currentProviders(value: unknown) {
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
return value.all
.filter(record)
.flatMap((provider) =>
typeof provider.id === "string" && typeof provider.name === "string"
? [{ id: provider.id, name: provider.name, package: provider.id }]
: [],
)
const connected = new Set(
Array.isArray(value.connected) ? value.connected.filter((id) => typeof id === "string") : [],
)
return value.all.filter(record).flatMap((provider) =>
typeof provider.id === "string" && typeof provider.name === "string"
? [
{
id: provider.id,
name: provider.name,
package: provider.id,
activation: connected.has(provider.id) ? "enabled" : "auto",
},
]
: [],
)
}
function currentModels(value: unknown) {
@@ -584,7 +505,7 @@ function currentDefaultModel(value: unknown) {
function currentPermission(value: unknown) {
const permission = value as Record<string, unknown>
if (permission.action) return permission
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
const tool = permission.tool as { messageID?: string; callID?: string; id?: string } | undefined
return {
id: permission.id,
sessionID: permission.sessionID,
@@ -593,7 +514,9 @@ function currentPermission(value: unknown) {
save: permission.always,
metadata: permission.metadata,
source:
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
tool?.messageID && (tool.id || tool.callID)
? { type: "tool", messageID: tool.messageID, id: tool.id ?? tool.callID }
: undefined,
}
}
@@ -634,202 +557,6 @@ export function currentSession(session: { id: string } & Record<string, unknown>
}
}
export function currentMessage(value: unknown): SessionMessageInfo {
if (isCurrentMessage(value)) return value
if (!record(value) || !record(value.info) || !Array.isArray(value.parts)) throw new Error("Invalid message fixture")
const info = value.info
const parts = value.parts.filter(record)
if (typeof info.id !== "string" || !record(info.time) || typeof info.time.created !== "number")
throw new Error("Invalid legacy message fixture")
const time = {
created: info.time.created,
...(typeof info.time.completed === "number" ? { completed: info.time.completed } : {}),
}
if (info.role === "user") {
return {
id: info.id,
type: "user",
time: { created: time.created },
text: parts
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
.join("\n"),
files: parts.flatMap((part) => (part.type === "file" ? legacyFile(part) : [])),
agents: parts.flatMap((part) => (part.type === "agent" ? legacyAgent(part) : [])),
}
}
if (info.role !== "assistant") throw new Error("Invalid legacy message role")
return {
id: info.id,
type: "assistant",
time,
agent: typeof info.agent === "string" ? info.agent : typeof info.mode === "string" ? info.mode : "build",
model: {
id: typeof info.modelID === "string" ? info.modelID : "model",
providerID: typeof info.providerID === "string" ? info.providerID : "provider",
...(typeof info.variant === "string" ? { variant: info.variant } : {}),
},
content: parts.flatMap((part) => legacyAssistantContent(part, time.created)),
...(typeof info.cost === "number" ? { cost: info.cost } : {}),
...(tokens(info.tokens) ? { tokens: tokens(info.tokens) } : {}),
...(structuredError(info.error) ? { error: structuredError(info.error) } : {}),
...(finish(info.finish) ? { finish: finish(info.finish) } : {}),
}
}
function isCurrentMessage(value: unknown): value is SessionMessageInfo {
return record(value) && typeof value.id === "string" && typeof value.type === "string" && !record(value.info)
}
function legacyFile(part: Record<string, unknown>): PromptFileAttachment[] {
if (typeof part.mime !== "string" || typeof part.url !== "string") return []
const data = part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? ""
const source = record(part.source) ? part.source : undefined
const sourceText = source && record(source.text) ? source.text : undefined
const mention = mentionFrom(sourceText)
const uri = source?.type === "resource" && typeof source.uri === "string" ? source.uri : part.url
return [
{
data,
mime: part.mime,
source: part.url.startsWith("data:") ? { type: "inline" } : { type: "uri", uri },
...(typeof part.filename === "string" ? { name: part.filename } : {}),
...(mention ? { mention } : {}),
},
]
}
function legacyAgent(part: Record<string, unknown>): PromptAgentAttachment[] {
if (typeof part.name !== "string") return []
const mention = mentionFrom(record(part.source) ? part.source : undefined)
return [{ name: part.name, ...(mention ? { mention } : {}) }]
}
function mentionFrom(value: Record<string, unknown> | undefined) {
if (!value || typeof value.value !== "string" || typeof value.start !== "number" || typeof value.end !== "number")
return
return { text: value.value, start: value.start, end: value.end }
}
function legacyAssistantContent(part: Record<string, unknown>, created: number): SessionMessageAssistant["content"] {
if (part.type === "text" && typeof part.text === "string")
return [
{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) },
]
if (part.type === "reasoning" && typeof part.text === "string") {
const time = record(part.time) ? part.time : undefined
return [
{
type: "reasoning",
text: part.text,
...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}),
...(time && typeof time.start === "number"
? {
time: {
created: time.start,
...(typeof time.end === "number" ? { completed: time.end } : {}),
},
}
: {}),
},
]
}
if (part.type !== "tool" || typeof part.id !== "string" || typeof part.tool !== "string" || !record(part.state))
return []
const state = part.state
const time = record(state.time) ? state.time : undefined
const toolTime = {
created: time && typeof time.start === "number" ? time.start : created,
...(time && typeof time.start === "number" ? { ran: time.start } : {}),
...(time && typeof time.end === "number" ? { completed: time.end } : {}),
}
const input = jsonRecord(state.input) ?? {}
const metadata = jsonRecord(state.metadata)
const base = {
type: "tool" as const,
id: typeof part.callID === "string" ? part.callID : part.id,
name: part.tool,
time: toolTime,
...(typeof part.executed === "boolean" ? { executed: part.executed } : {}),
...(jsonRecord(part.providerState) ? { providerState: jsonRecord(part.providerState) } : {}),
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
}
if (state.status === "pending")
return [
{
...base,
state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) },
},
]
if (state.status === "completed")
return [
{
...base,
state: {
status: "completed",
input,
content: [{ type: "text", text: typeof state.output === "string" ? state.output : "" }],
...(metadata ? { metadata } : {}),
},
},
]
if (state.status === "error")
return [
{
...base,
state: {
status: "error",
input,
error: structuredError(state.error) ?? { type: "ToolError", message: "Tool failed" },
...(metadata ? { metadata } : {}),
},
},
]
return [{ ...base, state: { status: "running", input, metadata: metadata ?? {} } }]
}
function structuredError(value: unknown): SessionStructuredError | undefined {
if (typeof value === "string") return { type: "Error", message: value }
if (!record(value)) return
if (typeof value.type === "string" && typeof value.message === "string")
return { type: value.type, message: value.message }
if (typeof value.name !== "string" || !record(value.data) || typeof value.data.message !== "string") return
return { type: value.name, message: value.data.message }
}
function tokens(value: unknown): SessionMessageAssistant["tokens"] | undefined {
if (!record(value) || !record(value.cache)) return
if (
typeof value.input !== "number" ||
typeof value.output !== "number" ||
typeof value.reasoning !== "number" ||
typeof value.cache.read !== "number" ||
typeof value.cache.write !== "number"
)
return
return {
input: value.input,
output: value.output,
reasoning: value.reasoning,
cache: { read: value.cache.read, write: value.cache.write },
}
}
function finish(value: unknown): SessionMessageAssistant["finish"] | undefined {
if (
value === "stop" ||
value === "length" ||
value === "tool-calls" ||
value === "content-filter" ||
value === "error" ||
value === "unknown"
)
return value
}
function jsonRecord(value: unknown): Record<string, JsonValue> | undefined {
if (!record(value)) return
return Object.fromEntries(
@@ -871,18 +598,3 @@ function sse(route: Route, events?: unknown[], retry?: number) {
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
})
}
function currentEvent(input: unknown) {
if (!input || typeof input !== "object" || !("payload" in input)) return input
const envelope = input as { directory?: string; payload?: unknown }
if (!envelope.payload || typeof envelope.payload !== "object") return input
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
if (!payload.type) return input
return {
id: payload.id ?? `evt_mock_${Date.now()}`,
created: Date.now(),
type: payload.type,
data: payload.properties ?? {},
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
}
}
+13 -30
View File
@@ -1,4 +1,5 @@
import type { Page } from "@playwright/test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
export type SseConnectionRecord = {
id: number
@@ -27,7 +28,7 @@ export type SseEventOptions = {
marker?: string
}
export type SseTransport<T> = {
export type SseTransport<T extends OpenCodeEvent> = {
server: string
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
@@ -55,7 +56,7 @@ type BrowserTransport = Window & {
}
}
export async function installSseTransport<T>(
export async function installSseTransport<T extends OpenCodeEvent = OpenCodeEvent>(
page: Page,
options: { server: string; retry?: number },
): Promise<SseTransport<T>> {
@@ -93,21 +94,6 @@ export async function installSseTransport<T>(
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
`data: ${JSON.stringify(payload)}\n\n`,
].join("")
const currentEvent = (input: unknown) => {
if (!input || typeof input !== "object" || !("payload" in input)) return input
const envelope = input as { directory?: string; payload?: unknown }
if (!envelope.payload || typeof envelope.payload !== "object") return input
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
if (!payload.type) return input
return {
id: payload.id ?? `evt_mock_${Date.now()}`,
created: Date.now(),
type: payload.type,
data: payload.properties ?? {},
location:
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
}
}
const acknowledge = (
connection: Connection,
bytes: number,
@@ -155,13 +141,14 @@ export async function installSseTransport<T>(
output.forEach((chunk) => connection.controller.enqueue(chunk))
return acknowledge(connection, input.bytes.length, output.length)
}
const encoded = input.deliveries.map((delivery) => {
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
})
const encoded = input.deliveries.map((delivery) => ({
delivery,
payload: delivery.payload,
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
}))
encoded.forEach((item) => marker(item.delivery.options?.marker))
if (input.burst) {
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
const bytes = encoder.encode(encoded.map((item) => new TextDecoder().decode(item.bytes)).join(""))
connection.controller.enqueue(bytes)
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
}
@@ -266,15 +253,11 @@ export async function installSseTransport<T>(
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] })
},
heartbeat(eventOptions) {
const bytes = new TextEncoder().encode(": heartbeat\n\n")
return command({
type: "send",
deliveries: [
{
payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T,
options: eventOptions,
},
],
burst: false,
type: "raw",
bytes: Array.from(bytes),
marker: eventOptions?.marker,
})
},
writeRaw(value, cuts, marker) {
+7 -10
View File
@@ -1,6 +1,6 @@
import type { Page, TestInfo } from "@playwright/test"
import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./visual-stability/analyzer"
import { legacyVisualPlan, type LegacyVisualStabilityOptions } from "./visual-stability/invariant"
import { stabilityPlan, type VisualStabilityOptions } from "./visual-stability/invariant"
import type { CapturedFrame, VisualStabilityTrace } from "./visual-stability/model"
import { markVisualProbe, startVisualProbe, stopVisualProbe } from "./visual-stability/probe"
import type { VisualRegionDefinition } from "./visual-stability/regions"
@@ -25,22 +25,19 @@ export async function markVisualStability(page: Page, label: string) {
await markVisualProbe(page, label)
}
export function analyzeVisualStability(trace: VisualStabilityTrace, options: LegacyVisualStabilityOptions = {}) {
return analyzeVisualObservations(trace.samples, legacyVisualPlan(options))
export function analyzeVisualStability(trace: VisualStabilityTrace, options: VisualStabilityOptions = {}) {
return analyzeVisualObservations(trace.samples, stabilityPlan(options))
}
export function analyzeVisualStabilityByMarker(
trace: VisualStabilityTrace,
options: LegacyVisualStabilityOptions = {},
) {
return analyzeVisualTraceByMarker(trace, legacyVisualPlan(options))
export function analyzeVisualStabilityByMarker(trace: VisualStabilityTrace, options: VisualStabilityOptions = {}) {
return analyzeVisualTraceByMarker(trace, stabilityPlan(options))
}
export async function expectVisualStability(
testInfo: TestInfo,
name: string,
trace: VisualStabilityTrace,
options: LegacyVisualStabilityOptions = {},
options: VisualStabilityOptions = {},
) {
await reportVisualStability(
testInfo,
@@ -49,6 +46,6 @@ export async function expectVisualStability(
...trace,
frames: (trace as VisualStabilityTrace & { [capturedFrames]?: CapturedFrame[] })[capturedFrames] ?? [],
},
legacyVisualPlan(options),
stabilityPlan(options),
)
}
@@ -30,7 +30,7 @@ export type VisualPlan<RegionName extends string = string> = {
aggregateMotion?: boolean
}
export type LegacyVisualStabilityOptions<RegionName extends string = string> = {
export type VisualStabilityOptions<RegionName extends string = string> = {
flow?: RegionName[]
motionTolerance?: number
opacityFloor?: number
@@ -58,8 +58,8 @@ export function visualPlan<const Regions extends Record<string, VisualRegionDefi
return { ...options, regionNames: Object.keys(regions) as Extract<keyof Regions, string>[], invariants }
}
export function legacyVisualPlan<RegionName extends string>(
options: LegacyVisualStabilityOptions<RegionName> = {},
export function stabilityPlan<RegionName extends string>(
options: VisualStabilityOptions<RegionName> = {},
): VisualPlan<RegionName> {
const inferred =
options.inferRequired === false
-7
View File
@@ -2,13 +2,6 @@
var key = "opencode-theme-id"
var themeId = localStorage.getItem(key) || "oc-2"
if (themeId === "oc-1") {
themeId = "oc-2"
localStorage.setItem(key, themeId)
localStorage.removeItem("opencode-theme-css-light")
localStorage.removeItem("opencode-theme-css-dark")
}
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
+20 -73
View File
@@ -12,7 +12,6 @@ import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchPara
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import {
type Component,
createEffect,
createMemo,
createRenderEffect,
ErrorBoundary,
@@ -32,16 +31,15 @@ import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServersProvider, useServers } from "@/context/servers"
import { ServerConnection, ServersProvider } from "@/context/servers"
import { SettingsProvider } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider } from "@/context/sdk"
import { LocationProvider } from "@/context/location"
import { WslServersProvider } from "@/wsl/context"
import { DirectoryDataProvider } from "@/pages/directory-layout"
import { SessionUIProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { decode64 } from "@/utils/base64"
import { requireServerKey } from "./utils/session-route"
import { TargetSessionRouteContent } from "@/pages/session"
import { Home } from "@/pages/home"
@@ -49,36 +47,17 @@ import { ServerProvider } from "./context/server"
const NewSession = lazy(() => import("@/pages/new-session"))
const DirectoryDraftRedirect = () => {
const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const server = useServers()
const tabs = useTabs()
createEffect(() => {
if (search.draftId || !tabs.ready()) return
const directory = decode64(params.dir)
if (!directory) return
tabs.newDraft({ server: ServerConnection.key(server.list[0])!, directory }, search.prompt)
})
return null
}
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string; id: string }>()
const params = useParams<{ serverKey: string }>()
const global = useGlobal()
const conn = createMemo(() => {
const key = requireServerKey(params.serverKey)
return global.servers.list().find((item) => ServerConnection.key(item) === key)
})
const conn = createMemo(() =>
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
)
return (
// Owns the server-identity remount. Session changes must not remount this subtree.
<Show when={requireServerKey(params.serverKey)} keyed>
<Show when={conn()} keyed>
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
</Show>
<Show when={conn()} keyed>
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
</Show>
)
}
@@ -87,14 +66,12 @@ function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
return (
<Show when={tabs.ready()}>
<Show
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
keyed
fallback={<Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
</Show>
<Show
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
keyed
fallback={tabs.ready() && <Navigate href="/" />}
>
{(draft) => <ResolvedDraftRoute draft={draft} />}
</Show>
)
}
@@ -109,13 +86,13 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
{(conn) => (
<ServerProvider conn={conn}>
<ModelsProvider directory={props.draft.directory}>
<SDKProvider directory={props.draft.directory}>
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
<LocationProvider directory={props.draft.directory}>
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
</SessionUIProvider>
</LocationProvider>
</ModelsProvider>
</ServerProvider>
)}
@@ -264,8 +241,6 @@ export function AppInterface(props: {
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
startup?: Promise<void>
}) {
// The visual layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
@@ -292,7 +267,6 @@ export function AppInterface(props: {
<SettingsProvider>
<GlobalProvider>
<Dynamic component={props.router ?? Router} root={Root}>
{/* Proper Routes */}
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route
@@ -305,36 +279,9 @@ export function AppInterface(props: {
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
{/* Legacy Routes */}
<Route>
<Route path="/:dir" component={DirectoryDraftRedirect} />
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
</Route>
</Dynamic>
</GlobalProvider>
</SettingsProvider>
</ServersProvider>
)
}
function LegacySessionRedirect() {
const server = useServers()
const tabs = useTabs()
const params = useParams<{ id: string }>()
return (
<Show when={tabs.ready()}>
<Navigate
href={sessionHref(
legacySessionServer(
tabs.store.filter((item) => item.type === "session"),
params.id,
ServerConnection.key(server.list[0]!),
),
params.id,
)}
/>
</Show>
)
}
@@ -13,7 +13,6 @@ import { type Component, createMemo, createUniqueId, For, Match, onMount, Show,
import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/components/external-link"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useIntegrations } from "@/hooks/use-integrations"
@@ -258,7 +257,6 @@ function ProviderConnection(props: {
setBack: (handler: () => void) => void
}) {
const dialog = useDialog()
const serverSync = useServerSync()
const params = useParams()
const language = useLanguage()
const providers = useProviders(() => props.directory)
@@ -280,10 +278,7 @@ function ProviderConnection(props: {
const provider = createMemo(() => ({
id: props.provider,
name:
providers.all().get(props.provider)?.name ??
serverSync.data.provider.all.get(props.provider)?.name ??
controller.integration()?.name ??
props.provider,
providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
}))
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
@@ -9,7 +9,7 @@ import { showToast } from "@/utils/toast"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { ExternalLink } from "@/components/external-link"
import { useServerSync } from "@/context/server-sync"
import { useData } from "@/context/server"
import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
@@ -41,7 +41,7 @@ export function DialogCustomProvider(props: Props) {
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const dialog = useDialog()
const serverSync = useServerSync()
const data = useData()
const language = useLanguage()
const [form, setForm] = createStore<FormState>({
@@ -116,8 +116,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const output = validateCustomProvider({
form,
t: language.t,
disabledProviders: serverSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
// TODO: Restore disabled-provider validation when V2 exposes config reads.
disabledProviders: [],
existingProviderIDs: new Set((data.location.provider.list() ?? []).map((provider) => provider.id)),
})
batch(() => {
setForm("err", output.err)
@@ -11,7 +11,7 @@ import { For, Show, createSignal, startTransition } from "solid-js"
import { useLanguage } from "@/context/language"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/servers"
import { SDKProvider } from "@/context/sdk"
import { LocationProvider } from "@/context/location"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { createEditProjectModel } from "./edit-project"
import { ProjectSettingsExtensions } from "./project-settings-extensions"
@@ -22,9 +22,9 @@ import "./dialog-edit-project-v2.css"
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
return (
<SettingsServerDataScope server={props.server}>
<SDKProvider directory={props.project.worktree}>
<LocationProvider directory={props.project.worktree}>
<ProjectSettingsDialog project={props.project} server={props.server} />
</SDKProvider>
</LocationProvider>
</SettingsServerDataScope>
)
}
+27 -24
View File
@@ -1,16 +1,19 @@
import { Component, createMemo } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { useData } from "@/context/server"
import { usePrompt } from "@/context/prompt"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { showToast } from "@/utils/toast"
import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@/types"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { extractPromptFromParts } from "@/utils/prompt"
import { normalizeSessionMessages } from "@/utils/session-message"
import { useWorkspaceLocation } from "@/context/location"
import { useServer } from "@/context/server"
import { sessionHref } from "@/utils/session-route"
interface ForkableMessage {
id: string
@@ -25,29 +28,27 @@ function formatTime(date: Date): string {
export const DialogFork: Component = () => {
const params = useParams()
const navigate = useNavigate()
const sync = useSync()
const sdk = useSDK()
const data = useData()
const serverSDK = useServerSDK()
const location = useWorkspaceLocation()
const prompt = usePrompt()
const dialog = useDialog()
const language = useLanguage()
const server = useServer()
const messages = createMemo((): ForkableMessage[] => {
const sessionID = params.id
if (!sessionID) return []
const msgs = sync().data.message[sessionID] ?? []
const msgs = data.session.message.list(sessionID)
const result: ForkableMessage[] = []
for (const message of msgs) {
if (message.role !== "user") continue
const parts = sync().data.part[message.id] ?? []
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
if (!textPart) continue
if (message.type !== "user" || !message.text) continue
result.push({
id: message.id,
text: textPart.text.replace(/\n/g, " ").slice(0, 200),
text: message.text.replace(/\n/g, " ").slice(0, 200),
time: formatTime(new Date(message.time.created)),
})
}
@@ -60,20 +61,22 @@ export const DialogFork: Component = () => {
const sessionID = params.id
if (!sessionID) return
const restored = extractPromptFromParts(
normalizeSessionMessages(sessionID, data.session.message.list(sessionID)).parts.get(item.id) ?? [],
{
directory: location().directory,
attachmentName: language.t("common.attachment"),
},
)
const dir = base64Encode(location().directory)
const parts = sync().data.part[item.id] ?? []
const restored = extractPromptFromParts(parts, {
directory: sdk().directory,
attachmentName: language.t("common.attachment"),
})
const dir = base64Encode(sdk().directory)
sdk()
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
serverSDK.api.session
.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
.then((forked) => {
data.session.remember(forked)
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id })
navigate(`/${dir}/session/${forked.id}`)
navigate(sessionHref(server.key, forked.id))
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
@@ -1,5 +1,6 @@
import { Component, createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync"
import { useData } from "@/context/server"
import { useWorkspaceLocation } from "@/context/location"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
@@ -14,12 +15,13 @@ const statusLabels = {
} as const
export const DialogSelectMcp: Component = () => {
const sync = useSync()
const data = useData()
const sdk = useWorkspaceLocation()
const language = useLanguage()
const items = createMemo(() =>
Object.entries(sync().data.mcp ?? {})
.map(([name, status]) => ({ name, status: status.status }))
(data.location.mcp.server.list({ directory: sdk().directory }) ?? [])
.map((server) => ({ name: server.name, status: server.status.status }))
.sort((a, b) => a.name.localeCompare(b.name)),
)
@@ -47,7 +49,8 @@ export const DialogSelectMcp: Component = () => {
}}
>
{(i) => {
const mcpStatus = () => sync().data.mcp[i.name]
const mcpStatus = () =>
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)?.status
const status = () => mcpStatus()?.status
const statusLabel = () => {
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
@@ -1,13 +1,12 @@
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
import { type Component, For, Show, createEffect, createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language"
import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useSync } from "@/context/sync"
import { useData } from "@/context/server"
import { ExternalLink } from "./external-link"
type SkillItem = {
@@ -15,7 +14,6 @@ type SkillItem = {
location: string
}
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
const ExtensionCard: Component<{ children: unknown }> = (props) => (
@@ -23,9 +21,8 @@ const ExtensionCard: Component<{ children: unknown }> = (props) => (
)
const ExtensionRow: Component<{
icon: "mcp" | "cube" | "post-skill" | "code"
icon: "mcp" | "cube" | "post-skill"
name: string
status?: string
children?: unknown
}> = (props) => (
<div class="project-settings-extension-row">
@@ -33,14 +30,6 @@ const ExtensionRow: Component<{
<Icon name={props.icon} class="project-settings-extension-row-icon" />
<span class="project-settings-extension-row-name">{props.name}</span>
</div>
<Show when={props.status}>
{(status) => (
<span class="project-settings-extension-row-status">
<span class="project-settings-extension-row-status-dot" />
{status()}
</span>
)}
</Show>
{props.children as any}
</div>
)
@@ -75,56 +64,55 @@ const SharedSection: Component<{
export const ProjectSettingsExtensions: Component = () => {
const language = useLanguage()
const serverSDK = useServerSDK()
const directorySDK = useSDK()
const serverSync = useServerSync()
const sync = useSync()
const toggleMcp = useMcpToggle()
const directorySDK = useWorkspaceLocation()
const data = useData()
const toggleMcp = useMcpToggle(() => directorySDK().directory)
createEffect(() => {
if (serverSDK.connection.status() !== "connected") return
const ref = { directory: directorySDK().directory }
void Promise.all([
data.location.mcp.server.sync(),
data.location.skill.sync(),
data.location.mcp.server.sync(ref),
data.location.skill.sync(ref),
]).catch(() => undefined)
})
const [serverMcp] = createResource(
serverSDK,
(sdk) =>
sdk.api.mcp
.list()
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
.catch(() => ({})),
{ initialValue: {} },
)
const globalMcpNames = createMemo(() =>
[...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
[...new Set((data.location.mcp.server.list() ?? []).map((server) => server.name))].sort(),
)
const projectMcpNames = createMemo(() => {
const shared = new Set(globalMcpNames())
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
if (configured.length > 0) return configured.sort()
return Object.keys(sync().data.mcp ?? {})
return (data.location.mcp.server.list({ directory: directorySDK().directory }) ?? [])
.map((server) => server.name)
.filter((name) => !shared.has(name))
.sort()
})
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
const mcpEnabled = (name: string) =>
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)?.status
.status === "connected"
const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
const [globalPluginList] = createResource(
() => serverSDK.connection.status() === "connected",
() => serverSDK.api.plugin.list().then((result) => result.data),
)
const [projectPluginList] = createResource(
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
)
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
})
const [serverSkills] = createResource(
serverSDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const [directorySkills] = createResource(
directorySDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill
.list({ location: { directory: sdk.directory } })
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
const projectSkills = createMemo(() => {
const shared = new Set(serverSkills.latest.map(skillKey))
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
const shared = new Set(serverSkills().map(skillKey))
return (data.location.skill.list({ directory: directorySDK().directory }) ?? []).filter(
(item) => !shared.has(skillKey(item)),
)
})
const mcpRows = (items: string[]) => (
@@ -165,7 +153,7 @@ export const ProjectSettingsExtensions: Component = () => {
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
<TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger>
{/* TODO: Restore LSP status when V2 exposes it. */}
</TabsV2.List>
<TabsV2.Content value="mcps">
@@ -205,33 +193,10 @@ export const ProjectSettingsExtensions: Component = () => {
<Show when={projectSkills().length > 0}>
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
</Show>
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
</div>
</TabsV2.Content>
<TabsV2.Content value="lsps">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
<span>{language.t("project.settings.extensions.lsp.description")}</span>
</div>
<Show when={sync().data.lsp.length > 0}>
<ExtensionCard>
<For each={sync().data.lsp}>
{(item) => (
<ExtensionRow
icon="code"
name={item.name || item.id}
status={
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
}
/>
)}
</For>
</ExtensionCard>
</Show>
</div>
</TabsV2.Content>
</TabsV2>
</div>
)
+12 -15
View File
@@ -22,8 +22,8 @@ import { useLayout } from "@/context/layout"
import { usePermission } from "@/context/permission"
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { createSessionTabs } from "@/pages/session/helpers"
import { showToast } from "@/utils/toast"
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
@@ -81,8 +81,8 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
}
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
const sdk = useSDK()
const sync = useSync()
const sdk = useWorkspaceLocation()
const data = useData()
const files = useFile()
const layout = useLayout()
const comments = useComments()
@@ -113,8 +113,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
return [...result, path]
}, [])
})
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
const attachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
@@ -228,8 +228,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
const referenceDescription = (reference: ReferenceInfo) =>
reference.source.type === "git" ? reference.source.repository : reference.source.path
const references = createMemo(() =>
sync()
.data.reference.filter((reference) => !reference.hidden)
(data.location.reference.list({ directory: sdk().directory }) ?? [])
.filter((reference) => !reference.hidden)
.map((reference) => ({
id: `reference:${reference.name}`,
kind: "reference" as const,
@@ -248,7 +248,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
})),
)
const resources = createMemo(() =>
Object.values(sync().data.mcp_resource).map((resource) => ({
(data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map((resource) => ({
id: `resource:${resource.server}:${resource.uri}`,
kind: "resource" as const,
label: `@${resource.name}`,
@@ -294,7 +294,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
})),
])
const slashCommands = createMemo(() => [
...sync().data.command.map((item) => ({
...(data.location.command.list({ directory: sdk().directory }) ?? []).map((item) => ({
id: `custom.${item.name}`,
trigger: item.name,
title: item.name,
@@ -354,7 +354,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, props, sync, layout, files, comments)
if (item) openComment(item, props, layout, files, comments)
},
onEditor(element) {
editor = element as HTMLDivElement
@@ -557,7 +557,6 @@ function PromptInputV2ModelControl(props: {
function openComment(
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
props: PromptInputV2ControllerProps,
sync: ReturnType<typeof useSync>,
layout: ReturnType<typeof useLayout>,
files: ReturnType<typeof useFile>,
comments: ReturnType<typeof useComments>,
@@ -575,9 +574,7 @@ function openComment(
})
})
}
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
const review =
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
const review = item.commentOrigin === "review"
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
if (review) {
layout.fileTree.setTab("changes")
@@ -1,8 +1,6 @@
// @ts-nocheck
import { createStore } from "solid-js/store"
import type { Todo } from "@/types"
import { createPromptState } from "@/context/prompt"
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
import { createPromptInputHistory, PromptInput } from "./prompt-input"
function createPromptInputStoryRuntime() {
@@ -111,93 +109,6 @@ function PromptInputExample() {
)
}
const todos: Todo[] = [
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
]
function PromptInputWithOpenDock() {
const input = createPromptInputStoryRuntime()
const [controls, setControls] = createStore({
agent: "build",
activeTab: undefined as string | undefined,
todoCollapsed: false,
})
const inputControls = {
agents: {
available: [],
options: ["build"],
get current() {
return controls.agent
},
loading: false,
visible: true,
select: (agent?: string) => setControls("agent", agent ?? "build"),
},
model: {
selection: {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: { list: () => [], current: () => undefined, set: () => {} },
},
paid: true,
loading: false,
},
session: {
id: "story-session",
tabs: {
active: () => controls.activeTab,
all: () => [],
open: () => {},
setActive: (tab: string) => setControls("activeTab", tab),
},
reviewPanel: { opened: () => false, open: () => {} },
},
}
const state = {
blocked: () => false,
questionRequest: () => undefined,
permissionRequest: () => undefined,
permissionResponding: () => false,
decide: () => {},
todos: () => todos,
dock: () => true,
closing: () => false,
opening: () => false,
}
return (
<SessionComposerRegion
controller={createSessionComposerRegionController({
state,
sessionKey: () => "story-session",
sessionID: () => "story-session",
prompt: input.state,
ready: () => true,
centered: () => false,
todo: {
collapsed: () => controls.todoCollapsed,
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
},
followup: () => undefined,
revert: () => undefined,
onResponseSubmit: () => {},
openParent: () => {},
setPromptRef: () => {},
setDockRef: () => {},
})}
promptInput={
<PromptInput
controls={inputControls}
{...input}
ref={() => {}}
newSessionWorktree=""
onNewSessionWorktreeReset={() => {}}
/>
}
/>
)
}
export default {
title: "App/PromptInput",
id: "app-prompt-input",
@@ -212,12 +123,3 @@ export const Basic = {
</div>
),
}
export const DockAlreadyOpen = {
render: () => (
<div class="pt-10">
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
<PromptInputWithOpenDock />
</div>
),
}
@@ -16,6 +16,21 @@ export type PromptInputHistory = {
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
export function upgradeHistoryState(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value) || !("entries" in value)) return value
const entries = value.entries
if (!Array.isArray(entries)) return value
return {
...value,
entries: entries.flatMap((entry): PromptHistoryStoredEntry[] => {
if (Array.isArray(entry)) return [{ prompt: clonePromptParts(entry as Prompt), comments: [] }]
if (!entry || typeof entry !== "object" || !("prompt" in entry) || !Array.isArray(entry.prompt)) return []
if (!("comments" in entry) || !Array.isArray(entry.comments)) return []
return [entry as PromptHistoryStoredEntry]
}),
}
}
function createPromptInputHistoryStore(
normal: Store<PromptHistoryState>,
setNormal: SetStoreFunction<PromptHistoryState>,
@@ -42,11 +57,11 @@ export function createPromptInputHistory(): PromptInputHistory {
export function createPersistedPromptInputHistory() {
const [normal, setNormal, normalInit] = persisted(
Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])),
{ ...Persist.prompt(Persist.global("prompt-history")), migrate: upgradeHistoryState },
createStore<PromptHistoryState>({ entries: [] }),
)
const [shell, setShell, shellInit] = persisted(
Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])),
{ ...Persist.prompt(Persist.global("prompt-history-shell")), migrate: upgradeHistoryState },
createStore<PromptHistoryState>({ entries: [] }),
)
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
@@ -3,16 +3,17 @@ import type { Prompt } from "@/context/prompt"
import {
canNavigateHistoryAtCursor,
clonePromptParts,
normalizePromptHistoryEntry,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
type PromptHistoryComment,
} from "./history"
import { upgradeHistoryState } from "./history-store"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
const entry = (value: string) => ({ prompt: text(value), comments: [] })
const comment = (id: string, value = "note"): PromptHistoryComment => ({
id,
path: "src/a.ts",
@@ -42,7 +43,7 @@ describe("prompt-input history", () => {
})
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
const entries = [text("third"), text("second"), text("first")]
const entries = [entry("third"), entry("second"), entry("first")]
const up = navigatePromptHistory({
direction: "up",
entries,
@@ -95,10 +96,10 @@ describe("prompt-input history", () => {
expect(up.entry.comments).toEqual([comment("c1")])
})
test("normalizePromptHistoryEntry supports legacy prompt arrays", () => {
const entry = normalizePromptHistoryEntry(text("legacy"))
expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy")
expect(entry.comments).toEqual([])
test("upgrades stored prompt arrays once at the persistence boundary", () => {
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
entries: [{ prompt: text("stored"), comments: [] }],
})
})
test("helpers clone prompt and count text content length", () => {
@@ -20,7 +20,7 @@ export type PromptHistoryEntry = {
comments: PromptHistoryComment[]
}
export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
export type PromptHistoryStoredEntry = PromptHistoryEntry
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
const position = Math.max(0, Math.min(cursor, text.length))
@@ -60,12 +60,6 @@ export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
}
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
if (Array.isArray(entry)) {
return {
prompt: clonePromptParts(entry),
comments: [],
}
}
return {
prompt: clonePromptParts(entry.prompt),
comments: clonePromptHistoryComments(entry.comments),
@@ -11,18 +11,6 @@ type SessionCreateInput = {
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
}
const admitted: Array<{
directory?: string
sessionID: string
messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
comments: unknown[]
}> = []
const confirmed: unknown[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const sentShellDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
@@ -36,12 +24,10 @@ const switchedModels: Array<{
}> = []
const sessionRequestOrder: string[] = []
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
const syncedServers: string[] = []
const admittedServers: string[] = []
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
const navigations: string[] = []
let serverSessionSyncs = 0
let restoredPrompts = 0
let clearEchoCalls = 0
let params: { id?: string } = {}
let search: { draftId?: string } = {}
@@ -52,15 +38,12 @@ let createWorktreeGate: Promise<void> | undefined
let worktreeFailure: Error | undefined
let locationFailure: Error | undefined
let promptFailure: Error | undefined
let clearEchoResult = true
let worktreeCreates = 0
let activeSDK = "server-a"
let activeServerSync = "server-a"
let activeDirectorySync = "server-a"
let activeServer = "server-a"
let commands: Array<{ name: string }> = []
let worktreeDirectory = "/repo/new-0"
let worktreeID = 0
const draftServers: Record<string, string> = {}
const sessionDirectories: Record<string, string> = {}
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
@@ -178,7 +161,7 @@ beforeAll(async () => {
const rootClient = clientFor("/repo/main")
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useNavigate: () => (href: string) => navigations.push(href),
useParams: () => params,
useLocation: () => ({}),
useSearchParams: () => [search, () => undefined],
@@ -191,7 +174,10 @@ beforeAll(async () => {
}))
mock.module("@opencode-ai/core/util/encode", () => ({
base64Decode: (value: string) => value,
base64Encode: (value: string) => value,
checksum: (value: string) => value,
sampledChecksum: (value: string) => value,
}))
mock.module("@/context/local", () => ({
@@ -213,13 +199,8 @@ beforeAll(async () => {
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
})
mock.module("@/context/server", () => ({
useServer: () => ({ key: "server-key" }),
}))
mock.module("@/context/tabs", () => ({
useTabs: () => ({
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
updateDraft: (draftID: string, draft: { worktree?: string }) => {
updatedDrafts.push({ draftID, ...draft })
},
@@ -233,83 +214,35 @@ beforeAll(async () => {
usePrompt: () => prompt,
}))
mock.module("@/context/sdk", () => ({
useSDK: () => {
mock.module("@/context/location", () => ({
useWorkspaceLocation: () => {
return () => ({
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
api: rootClient.api,
url: "http://localhost:4096",
})
},
}))
mock.module("@/context/sync", () => ({
useSync: () => () => {
const server = activeDirectorySync
return {
data: { command: commands, project: "project" },
session: {
inbox: {
echo: (value: {
directory?: string
sessionID: string
messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
comments: unknown[]
}) => {
admittedServers.push(server)
admitted.push(value)
},
confirm: (value: unknown) => {
confirmed.push(value)
},
clearEcho: () => {
clearEchoCalls++
return clearEchoResult
},
},
},
set: () => undefined,
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
}
},
mock.module("@/context/server-sdk", () => ({
useServerSDK: () => ({
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
api: rootClient.api,
}),
}))
mock.module("@/context/server-sync", () => ({
useServerSync: () => {
const server = activeServerSync
return {
session: {
remember: () => undefined,
set: () => undefined,
sync: async () => {
serverSessionSyncs++
},
mock.module("@/context/server", () => ({
useServer: () => ({ key: activeServer }),
useData: () => ({
session: {
remember: () => undefined,
setStatus: () => undefined,
},
location: {
info: () => ({ project: { id: "project", directory: "/repo/main" } }),
command: {
list: () => commands,
},
child: (directory: string) => {
syncedServers.push(server)
storedSessions[directory] ??= []
return [
{ session: storedSessions[directory] },
(...args: unknown[]) => {
if (args[0] !== "session") return
const next = args[1]
if (typeof next === "function") {
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
return
}
if (Array.isArray(next)) {
storedSessions[directory] = next as Array<{ id: string; title?: string }>
}
},
]
},
}
},
},
}),
}))
mock.module("@/context/platform", () => ({
@@ -330,8 +263,6 @@ beforeAll(async () => {
beforeEach(() => {
createdSessions.length = 0
admitted.length = 0
confirmed.length = 0
promotedDrafts.length = 0
updatedDrafts.length = 0
sentCommands.length = 0
@@ -340,11 +271,9 @@ beforeEach(() => {
switchedAgents.length = 0
switchedModels.length = 0
sessionRequestOrder.length = 0
syncedServers.length = 0
admittedServers.length = 0
promptCaptures.length = 0
navigations.length = 0
restoredPrompts = 0
clearEchoCalls = 0
params = {}
search = {}
sentShell.length = 0
@@ -352,8 +281,7 @@ beforeEach(() => {
selected = "/repo/worktree-a"
variant = undefined
activeSDK = "server-a"
activeServerSync = "server-a"
activeDirectorySync = "server-a"
activeServer = "server-a"
commands = []
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
worktreeDirectory = `/repo/new-${++worktreeID}`
@@ -363,11 +291,8 @@ beforeEach(() => {
worktreeFailure = undefined
locationFailure = undefined
promptFailure = undefined
clearEchoResult = true
worktreeCreates = 0
for (const key of Object.keys(draftServers)) delete draftServers[key]
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
})
const event = { preventDefault: () => undefined } as unknown as Event
@@ -414,6 +339,7 @@ describe("prompt submit worktree selection", () => {
expect(worktreeCreates).toBe(1)
expect(createdSessions).toHaveLength(1)
expect(sentPrompts).toEqual([worktreeDirectory])
expect(navigations).toEqual(["/server/server-a/session/session-1"])
})
test("stops when the created workspace cannot initialize", async () => {
@@ -429,8 +355,6 @@ describe("prompt submit worktree selection", () => {
test("keeps async submission effects bound to the initiating context", async () => {
search = { draftId: "draft-1" }
draftServers["draft-1"] = "project-server-a"
draftServers["draft-2"] = "project-server-b"
let release = () => {}
createSessionGate = new Promise<void>((resolve) => {
release = resolve
@@ -442,18 +366,15 @@ describe("prompt submit worktree selection", () => {
const result = submit.handleSubmit(event)
activeSDK = "server-b"
activeServerSync = "server-b"
activeDirectorySync = "server-b"
activeServer = "server-b"
search.draftId = "draft-2"
release()
await result
await settle()
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
expect(admittedServers).toEqual(["server-a"])
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "server-a", sessionId: "session-1" }])
expect(promptCaptures.at(-1)?.target).toEqual({ server: "server-a", scope: ServerScope.local })
expect(submitted).toBe(0)
})
@@ -472,15 +393,6 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event)
await Bun.sleep(0)
expect(admitted).toHaveLength(1)
expect(admitted[0]).toMatchObject({
sessionID: "session-1",
text: "ls",
agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" },
})
expect(admitted[0]?.messageID).toStartWith("msg_")
expect(confirmed).toMatchObject([{ id: admitted[0]?.messageID, sessionID: "session-1" }])
expect(sentPrompts).toEqual(["/repo/main"])
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
expect(switchedModels).toEqual([
@@ -499,10 +411,9 @@ describe("prompt submit worktree selection", () => {
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
})
test("keeps a confirmed echo when the prompt response is lost", async () => {
test("restores the prompt when sending fails", async () => {
params = { id: "session-1" }
promptFailure = new Error("connection lost")
clearEchoResult = false
const submit = makeSubmit({
info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }),
})
@@ -510,9 +421,7 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event)
await settle()
expect(admitted).toHaveLength(1)
expect(clearEchoCalls).toBe(1)
expect(restoredPrompts).toBe(0)
expect(restoredPrompts).toBe(1)
})
test("submits slash commands through the current session API", async () => {
@@ -1,17 +1,16 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { Data } from "@opencode-ai/client/solid"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useData } from "@/context/server"
import { useLanguage } from "@/context/language"
import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { Identifier } from "@/utils/id"
import { getDirectory } from "@opencode-ai/core/util/path"
import { buildPromptRequest } from "./build-prompt-request"
@@ -21,6 +20,8 @@ import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
import { Event } from "@opencode-ai/schema/event"
import { blobDataUrl } from "@/utils/draft-store"
import { useServer } from "@/context/server"
import { sessionHref } from "@/utils/session-route"
const submitting = new Set<string>()
@@ -35,9 +36,8 @@ export type FollowupDraft = {
}
type FollowupSendInput = {
api: DirectorySDK["api"]["session"]
serverSync: ServerSync
sync: DirectorySync
api: ServerSDK["api"]["session"]
data: Data
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
draft: FollowupDraft
messageID?: string
@@ -53,17 +53,17 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
const images = draftImages(input.draft.prompt)
const setBusy = () => {
if (!input.optimisticBusy) return
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" })
input.data.session.setStatus(input.draft.sessionID, "running")
}
const setIdle = () => {
if (!input.optimisticBusy) return
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
input.data.session.setStatus(input.draft.sessionID, "idle")
}
const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
if (cmd && input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)) {
setBusy()
try {
const messageID = Identifier.ascending("message")
@@ -108,14 +108,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
})
setBusy()
input.sync.session.inbox.echo({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
messageID,
agent: input.draft.agent,
model: { ...input.draft.model, variant: input.draft.variant },
...request,
})
try {
const session = input.session()
@@ -137,22 +129,15 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
})
}
const admitted = await input.api.prompt({
await input.api.prompt({
sessionID: input.draft.sessionID,
id: messageID,
text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
})
input.sync.session.inbox.confirm(admitted)
return true
} catch (err) {
const failed = input.sync.session.inbox.clearEcho({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
messageID,
})
if (!failed) return true
setIdle()
throw err
}
@@ -186,9 +171,10 @@ type PromptSubmitInput = {
export function createPromptSubmit(input: PromptSubmitInput) {
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const serverSync = useServerSync()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const data = useData()
const server = useServer()
const local = useLocal()
const permission = usePermission()
const prompt = input.prompt
@@ -209,12 +195,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const abort = async () => {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
serverSync.session.set("todo", sessionID, [])
input.onAbort?.()
return sdk()
.api.session.interrupt({ sessionID })
return serverSDK.api.session.interrupt({ sessionID })
.catch(() => {})
}
@@ -241,21 +224,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
target.session.remember(info)
const [, setStore] = target.child(dir)
setStore("session", (list: SessionInfo[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
if (result.found) {
next[result.index] = info
return next
}
next.splice(result.index, 0, info)
return next
})
}
const handleSubmit = async (event: Event) => {
event.preventDefault()
@@ -288,15 +256,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
const submissionSDK = sdk()
const submissionSync = sync()
const submissionServerSync = serverSync
const submissionScope = submissionSDK.scope
const submissionServerSDK = serverSDK
const submissionData = data
const submissionScope = submissionServerSDK.scope
const submissionServer = server.key
const projectDirectory = submissionSDK.directory
const sessionID = params.id
const isNewSession = !sessionID
const currentSession = input.info()
const draftID = search.draftId
const draftServer = draftID ? tabs.draft(draftID).server : undefined
const capturePrompt = prompt.capture
const localSession = local.session
const resetWorktree = input.onNewSessionWorktreeReset
@@ -318,14 +286,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let sessionDirectory = projectDirectory
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await submissionSDK.api.worktree
const createdWorktree = await submissionServerSDK.api.worktree
.create({
projectID: submissionSync.data.project,
projectID: submissionData.location.info({ directory: projectDirectory })?.project.id ?? "",
strategy: "git",
directory: getDirectory(submissionSync.project?.worktree ?? projectDirectory),
directory: getDirectory(
submissionData.location.info({ directory: projectDirectory })?.project.directory ?? projectDirectory,
),
})
.then(async (created) => {
await submissionSDK.api.location.get({ location: { directory: created.directory } })
await submissionServerSDK.api.location.get({ location: { directory: created.directory } })
return created
})
.catch((err) => {
@@ -342,15 +312,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
sessionDirectory = worktreeSelection
}
if (sessionDirectory !== projectDirectory) {
submissionServerSync.child(sessionDirectory)
}
}
let session = currentSession
if (!session && isNewSession) {
const created = await submissionSDK.api.session
const created = await submissionServerSDK.api.session
.create({
agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
@@ -364,7 +330,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return undefined
})
if (created) {
seed(submissionServerSync, sessionDirectory, created)
submissionData.session.remember(created)
session = created
await startTransition(() => {
if (!session) return
@@ -376,12 +342,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
variant: variant ?? null,
})
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
if (draftID) tabs.promoteDraft(draftID, { server: submissionServer, sessionId: session.id })
else navigate(sessionHref(submissionServer, session.id))
submission.retarget(
capturePrompt(
{ dir: base64Encode(sessionDirectory), id: session.id },
{ server: draftServer, scope: submissionScope },
{ server: submissionServer, scope: submissionScope },
),
)
})
@@ -445,7 +411,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
void submissionSDK.api.session
void submissionServerSDK.api.session
.shell({
sessionID: session.id,
id: eventID,
@@ -464,12 +430,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
const customCommand = submissionData.location.command
.list({ directory: sessionDirectory })
?.find((command) => command.name === commandName)
if (customCommand) {
clearInput()
const messageID = Identifier.ascending("message")
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
void submissionSDK.api.session
submissionData.session.setStatus(session.id, "running")
void submissionServerSDK.api.session
.command({
sessionID: session.id,
id: messageID,
@@ -485,7 +453,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
),
})
.catch((err) => {
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
submissionData.session.setStatus(session.id, "idle")
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
@@ -503,16 +471,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput()
void sendFollowupDraft({
api: submissionSDK.api.session,
sync: submissionSync,
serverSync: submissionServerSync,
api: submissionServerSDK.api.session,
data: submissionData,
session: () => session,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
}).catch((err) => {
if (sessionDirectory === projectDirectory) {
submissionSync.set("session_status", session.id, { type: "idle" })
submissionData.session.setStatus(session.id, "idle")
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
@@ -1,9 +1,7 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { useQueryClient } from "@tanstack/solid-query"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { useData } from "@/context/server"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
@@ -18,8 +16,7 @@ export function createProviderConnectionController(options: {
}) {
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const queryClient = useQueryClient()
const data = useData()
const location = () => {
const directory = options.directory()
return directory ? { directory } : undefined
@@ -118,12 +115,15 @@ export function createProviderConnectionController(options: {
}
const finish = async () => {
cancelPolling()
const directory = options.directory()
const key = directory ? pathKey(directory) : null
const ref = location()
data.location.integration.invalidate(ref)
data.location.provider.invalidate(ref)
data.location.model.invalidate(ref)
await Promise.all([
queryClient.refetchQueries(serverSync.queryOptions.providers(key)).catch(() => undefined),
queryClient.refetchQueries(serverSync.queryOptions.integrations(key)).catch(() => undefined),
])
data.location.integration.sync(ref),
data.location.provider.sync(ref),
data.location.model.sync(ref),
]).catch(() => undefined)
if (polling.disposed) return
options.onComplete()
}
@@ -7,11 +7,10 @@ import { createMediaQuery } from "@solid-primitives/media"
import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { useData } from "@/context/server"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { getSessionContext } from "@/components/session/session-context-metrics"
import { useWorkspaceLocation } from "@/context/location"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
@@ -40,11 +39,11 @@ function openSessionContext(args: {
}
export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync()
const data = useData()
const file = useFile()
const layout = useLayout()
const language = useLanguage()
const sdk = useSDK()
const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory)
const { params, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -56,8 +55,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
fileBrowser: () => isDesktop() && !!params.id,
})
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const messages = createMemo(() => (params.id ? data.session.message.list(params.id) : []))
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
const usd = createMemo(
() =>
@@ -67,7 +66,21 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}),
)
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const context = createMemo(() => {
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
if (message?.type !== "assistant" || !message.tokens) return
const model = providers.all().get(message.model.providerID)?.models[message.model.id]
const total =
message.tokens.input +
message.tokens.output +
message.tokens.reasoning +
message.tokens.cache.read +
message.tokens.cache.write
return {
total,
usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
}
})
const cost = createMemo(() => {
return usd().format(info()?.cost ?? 0)
})
@@ -6,7 +6,7 @@ import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js
import type { Project } from "@/types"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useData } from "@/context/server"
import { useSettingsDialog } from "@/components/settings-dialog"
import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast"
@@ -26,11 +26,11 @@ export function SessionWorkspaceMenu(props: {
}) {
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const data = useData()
const openWorkspaces = useSettingsDialog("workspaces")
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID)
const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running"
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
const workspaces = () =>
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
@@ -1,8 +1,7 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useSync } from "@/context/sync"
import { useData } from "@/context/server"
import { checksum } from "@opencode-ai/core/util/encode"
import { findLast } from "@opencode-ai/core/util/array"
import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
@@ -11,25 +10,16 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@/types"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContext } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
system: "var(--syntax-info)",
user: "var(--syntax-success)",
assistant: "var(--syntax-property)",
tool: "var(--syntax-warning)",
other: "var(--syntax-comment)",
}
function Stat(props: { label: string; value: JSX.Element }) {
return (
<div class="flex flex-col gap-1">
@@ -39,12 +29,11 @@ function Stat(props: { label: string; value: JSX.Element }) {
)
}
function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) {
function RawMessageContent(props: { message: SessionMessageInfo; onRendered: () => void }) {
const file = createMemo(() => {
const parts = props.getParts(props.message.id)
const contents = JSON.stringify({ message: props.message, parts }, null, 2)
const contents = JSON.stringify(props.message, null, 2)
return {
name: `${props.message.role}-${props.message.id}.json`,
name: `${props.message.type}-${props.message.id}.json`,
contents,
cacheKey: checksum(contents),
}
@@ -62,8 +51,7 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) =>
}
function RawMessage(props: {
message: Message
getParts: (id: string) => Part[]
message: SessionMessageInfo
onRendered: () => void
time: (value: number | undefined) => string
}) {
@@ -73,7 +61,7 @@ function RawMessage(props: {
<Accordion.Trigger>
<div class="flex items-center justify-between gap-2 w-full">
<div class="min-w-0 truncate">
{props.message.role} <span class="text-text-base"> {props.message.id}</span>
{props.message.type} <span class="text-text-base"> {props.message.id}</span>
</div>
<div class="flex items-center gap-3">
<div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div>
@@ -84,52 +72,35 @@ function RawMessage(props: {
</StickyAccordionHeader>
<Accordion.Content class="bg-background-base">
<div class="p-3">
<RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} />
<RawMessageContent message={props.message} onRendered={props.onRendered} />
</div>
</Accordion.Content>
</Accordion.Item>
)
}
const emptyMessages: Message[] = []
const emptyUserMessages: UserMessage[] = []
const emptyMessages: SessionMessageInfo[] = []
export function SessionContextTab() {
const sync = useSync()
const data = useData()
const language = useLanguage()
const sdk = useSDK()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory)
const { params, view } = useSessionLayout()
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
const messages = createMemo(
() => {
const id = params.id
if (!id) return emptyMessages
return (sync().data.message[id] ?? []) as Message[]
return data.session.message.list(id)
},
emptyMessages,
{ equals: same },
)
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
const boundary = userMessages().findIndex((message) => message.id === revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
},
emptyUserMessages,
{ equals: same },
)
const usd = createMemo(
() =>
new Intl.NumberFormat(language.intl(), {
@@ -138,7 +109,28 @@ export function SessionContextTab() {
}),
)
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const ctx = createMemo(() => {
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
if (message?.type !== "assistant" || !message.tokens) return
const provider = providers.all().get(message.model.providerID)
const model = provider?.models[message.model.id]
const total =
message.tokens.input +
message.tokens.output +
message.tokens.reasoning +
message.tokens.cache.read +
message.tokens.cache.write
return {
message,
tokens: message.tokens,
providerLabel: provider?.name ?? message.model.providerID,
modelLabel: model?.name ?? message.model.id,
limit: model?.limit.context,
input: message.tokens.input,
total,
usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
}
})
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => {
@@ -147,8 +139,8 @@ export function SessionContextTab() {
const counts = createMemo(() => {
const all = messages()
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
const user = all.reduce((count, message) => count + (message.type === "user" ? 1 : 0), 0)
const assistant = all.reduce((count, message) => count + (message.type === "assistant" ? 1 : 0), 0)
return {
all: all.length,
user,
@@ -157,8 +149,7 @@ export function SessionContextTab() {
})
const systemPrompt = createMemo(() => {
const msg = findLast(visibleUserMessages(), (m) => !!m.system)
const system = msg?.system
const system = messages().findLast((message) => message.type === "system")?.text
if (!system) return
const trimmed = system.trim()
if (!trimmed) return
@@ -177,30 +168,6 @@ export function SessionContextTab() {
return c.modelLabel
})
const breakdown = createMemo(
on(
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
() => {
const c = ctx()
if (!c?.input) return []
return estimateSessionContextBreakdown({
messages: messages(),
parts: sync().data.part as Record<string, Part[] | undefined>,
input: c.input,
systemPrompt: systemPrompt(),
})
},
),
)
const breakdownLabel = (key: SessionContextBreakdownKey) => {
if (key === "system") return language.t("context.breakdown.system")
if (key === "user") return language.t("context.breakdown.user")
if (key === "assistant") return language.t("context.breakdown.assistant")
if (key === "tool") return language.t("context.breakdown.tool")
return language.t("context.breakdown.other")
}
const stats = [
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) },
@@ -210,12 +177,12 @@ export function SessionContextTab() {
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.tokens.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.tokens.reasoning) },
{
label: "context.stats.cacheTokens",
value: () =>
`${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
`${formatter().number(ctx()?.tokens.cache.read)} / ${formatter().number(ctx()?.tokens.cache.write)}`,
},
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
@@ -230,7 +197,7 @@ export function SessionContextTab() {
try {
const data = await fetchSessionExport({
sessionID,
api: sdk().api,
api: serverSDK.api,
})
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
@@ -252,8 +219,6 @@ export function SessionContextTab() {
let scroll: HTMLDivElement | undefined
let frame: number | undefined
let pending: { x: number; y: number } | undefined
const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[]
const restoreScroll = () => {
const el = scroll
if (!el) return
@@ -314,37 +279,6 @@ export function SessionContextTab() {
</For>
</div>
<Show when={breakdown().length > 0}>
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.breakdown.title")}</div>
<div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
<For each={breakdown()}>
{(segment) => (
<div
class="h-full"
style={{
width: `${segment.width}%`,
"background-color": BREAKDOWN_COLOR[segment.key],
}}
/>
)}
</For>
</div>
<div class="flex flex-wrap gap-x-3 gap-y-1">
<For each={breakdown()}>
{(segment) => (
<div class="flex items-center gap-1 text-11-regular text-text-weak">
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
<div>{breakdownLabel(segment.key)}</div>
<div class="text-text-weaker">{segment.percent.toLocaleString(language.intl())}%</div>
</div>
)}
</For>
</div>
<div class="hidden text-11-regular text-text-weaker">{language.t("context.breakdown.note")}</div>
</div>
</Show>
<Show when={systemPrompt()}>
{(prompt) => (
<div class="flex flex-col gap-2">
@@ -372,7 +306,7 @@ export function SessionContextTab() {
<Accordion multiple>
<For each={messages()}>
{(message) => (
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
<RawMessage message={message} onRendered={restoreScroll} time={formatter().time} />
)}
</For>
</Accordion>
@@ -1,8 +1,8 @@
import { Show, createMemo } from "solid-js"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { useWorkspaceLocation } from "@/context/location"
import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { Icon } from "@opencode-ai/ui/icon"
import { Mark } from "@opencode-ai/ui/logo"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
@@ -16,28 +16,32 @@ interface NewSessionViewProps {
}
export function NewSessionView(props: NewSessionViewProps) {
const sync = useSync()
const sdk = useSDK()
const sdk = useWorkspaceLocation()
const language = useLanguage()
const data = useData()
const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
const project = createMemo(() => {
const projectID = data.location.info({ directory: sdk().directory })?.project.id
return projectID ? data.project.get(projectID) : undefined
})
const sandboxes = createMemo(() => project()?.sandboxes ?? [])
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
const current = createMemo(() => {
const selection = props.worktree
if (options().includes(selection)) return selection
return MAIN_WORKTREE
})
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
const projectRoot = createMemo(() => project()?.canonical ?? sdk().directory)
const isWorktree = createMemo(() => {
const project = sync().project
if (!project) return false
return sdk().directory !== project.worktree
const current = project()
if (!current) return false
return sdk().directory !== current.canonical
})
const label = (value: string) => {
if (value === MAIN_WORKTREE) {
if (isWorktree()) return language.t("session.new.worktree.main")
const branch = sync().data.vcs?.branch
const branch = data.location.vcs.info({ directory: sdk().directory })?.branch.current
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
return language.t("session.new.worktree.main")
}
@@ -69,7 +73,7 @@ export function NewSessionView(props: NewSessionViewProps) {
{label(current())}
</div>
</div>
<Show when={sync().project}>
<Show when={project()}>
{(project) => (
<div class="flex items-start justify-center gap-3 min-h-5">
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
@@ -63,7 +63,7 @@ export const DialogSettings: Component<{
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined
}
if (route.type === "session") return serverCtx()?.sync.session.get(route.sessionId)?.location.directory
if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory
return undefined
})
@@ -1,10 +1,11 @@
import { Component, For, createMemo, createResource } from "solid-js"
import { Component, For, createEffect, createMemo, createResource } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { useLanguage } from "@/context/language"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useMcpToggle } from "@/context/mcp"
import { ExternalLink } from "../external-link"
import { InlineServerSelect } from "./parts/server-select"
import "./settings-v2.css"
@@ -21,35 +22,35 @@ interface PluginRowItem {
export const SettingsExtensionsV2: Component = () => {
const language = useLanguage()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const data = useData()
const [mcpList, { refetch: refetchMcp }] = createResource(
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.mcp.list().then((result) => result.data),
)
const toggleMcp = useMcpToggle(() => undefined, refetchMcp)
const mcps = createMemo<McpRowItem[]>(() => {
const configMcp = serverSync.data.config.mcp ?? {}
return Object.entries(configMcp).map(([name, config]) => ({
name,
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
return (mcpList.latest ?? []).map((server) => ({
name: server.name,
enabled: server.status.status === "connected",
}))
})
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
const before = serverSync.data.config.mcp ?? {}
const config = before[item.name]
if (typeof config !== "object" || config === null) return
const next = { ...before, [item.name]: { ...config, enabled: checked } }
serverSync.set("config", "mcp", next)
void serverSync.updateConfig({ mcp: next }).catch(() => serverSync.set("config", "mcp", before))
if (item.enabled === checked || toggleMcp.isPending) return
toggleMcp.mutate(item.name)
}
const plugins = createMemo<PluginRowItem[]>(() => {
const raw = serverSync.data.config.plugin ?? []
return raw.map((item) => {
const name = typeof item === "string" ? item : item[0]
return { name }
})
})
const [pluginList] = createResource(
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data),
)
const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
initialValue: [],
createEffect(() => {
if (serverSdk.connection.status() !== "connected") return
void data.location.skill.sync().catch(() => undefined)
})
const skills = () => data.location.skill.list() ?? []
return (
<>
@@ -2,7 +2,6 @@ import { createMemo, createResource, onMount, type Accessor } from "solid-js"
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { usePermission } from "@/context/permission"
import { useServerSync } from "@/context/server-sync"
import {
monoDefault,
monoFontFamily,
@@ -34,7 +33,7 @@ export function createPermissionScopeController(
const s = server()
const id = sessionID()
if (!s || !id) return undefined
return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory
return serverCtx()?.data.session.get(id)?.location.directory
})
return {
@@ -8,7 +8,6 @@ import { useIntegrations } from "@/hooks/use-integrations"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
import { DialogCustomProvider } from "../dialog-custom-provider"
import { SettingsServerScope } from "../settings-server-picker"
@@ -39,7 +38,6 @@ export const SettingsProvidersV2: Component<{
const dialog = useDialog()
const language = useLanguage()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
@@ -55,15 +53,12 @@ export const SettingsProvidersV2: Component<{
}
const connected = createMemo(() => {
return providers.connected().filter(
(provider) =>
provider.id !== "opencode" ||
Object.values(provider.models).some((model) => {
if (typeof model !== "object" || model === null || !("cost" in model)) return false
const cost = model.cost
return typeof cost === "object" && cost !== null && "input" in cost
}),
)
return providers
.connected()
.filter(
(provider) =>
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost.input > 0),
)
})
const popular = createMemo(() => {
@@ -94,10 +89,7 @@ export const SettingsProvidersV2: Component<{
const current = source(item)
if (current === "env") return language.t("settings.providers.tag.environment")
if (current === "api") return language.t("provider.connect.method.apiKey")
if (current === "config") {
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.config")
}
if (current === "config") return language.t("settings.providers.tag.config")
if (current === "custom") return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.other")
}
@@ -105,19 +97,12 @@ export const SettingsProvidersV2: Component<{
const canDisconnect = (item: ProviderItem) => {
const current = integration(item.id)
if (current) return current.connections.some((connection) => connection.type === "credential")
return source(item) !== "env" && !isConfigCustom(item.id)
const currentSource = source(item)
return currentSource !== "env" && currentSource !== "config"
}
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = serverSync.data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
return true
}
const disconnect = async (providerID: string, name: string) => {
const location = props.directory ? { directory: props.directory } : undefined
await serverSdk.api.integration
@@ -210,15 +210,6 @@
color: var(--v2-text-text-faint);
}
.settings-v2-legacy-panel {
height: 100%;
overflow: hidden;
}
.settings-v2-legacy-panel [data-component="dialog"] {
display: contents;
}
.settings-v2-provider-row {
display: flex;
flex-wrap: wrap;
@@ -1,6 +1,6 @@
import type { Component } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createStore } from "solid-js/store"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useQuery } from "@tanstack/solid-query"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
@@ -13,9 +13,10 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useData } from "@/context/server"
import { showToast } from "@/utils/toast"
import { getRelativeTime } from "@/utils/time"
import { sessionLabel } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key"
import { SettingsListV2 } from "./parts/list"
import { InlineServerSelect } from "./parts/server-select"
@@ -37,6 +38,7 @@ import {
} from "@/utils/workspace"
import { listAllSessions } from "@/utils/session"
import type { ServerScope } from "@/utils/server-scope"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import "./settings-v2.css"
type Workspace = {
@@ -48,7 +50,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const data = useData()
const tabs = useTabs()
const platform = usePlatform()
const [store, setStore] = createStore({
@@ -56,9 +58,22 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
transaction: undefined as "confirm" | "running" | undefined,
})
const workspaces = createMemo(() => workspaceInventory(serverSync.data.project))
const projectQuery = useQuery(() => ({
queryKey: [serverSDK.scope, "settings-workspace-projects"] as const,
queryFn: async () =>
Promise.all(
(await serverSDK.api.project.list()).map(async (project) => {
const worktrees = await serverSDK.api.worktree
.list({ projectID: project.id })
.catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))])
return normalizeProjectInfo({ ...project, worktrees })
}),
),
refetchOnMount: "always",
}))
const workspaces = createMemo(() => workspaceInventory(projectQuery.data ?? []))
const projects = createMemo(() =>
serverSync.data.project.filter((project) => managedWorkspaceDirectories(project).length > 0),
(projectQuery.data ?? []).filter((project) => managedWorkspaceDirectories(project).length > 0),
)
const projectName = (project: Project) => project.name || getFilename(project.worktree)
const projectOptions = createMemo(() => [
@@ -71,18 +86,21 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
const captureDeleteContext = () => {
const sdk = serverSDK
return { sdk, sync: serverSync, server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
return {
sdk,
data,
server: ServerConnection.key(sdk.server),
activeDirectory: props.activeDirectory,
}
}
const loadSessions = async (context = captureDeleteContext()) => {
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
return mergeWorkspaceSessionInventory(
fetched,
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
)
fetched.forEach(context.data.session.remember)
return mergeWorkspaceSessionInventory(fetched, context.data.session.list())
}
const sessionQuery = useQuery(() => ({
queryKey: [serverSDK.scope, null, "settings-workspace-sessions"] as const,
queryFn: () => loadSessions(),
queryFn: () => loadSessions().then(() => Date.now()),
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(
@@ -90,7 +108,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
sessionQuery.isSuccess ? sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory) : [],
sessionQuery.isSuccess ? sessionsForWorkspace(data.session.list(), workspace.directory) : [],
]),
),
)
@@ -176,25 +194,8 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
worktree: undefined,
})
})
clearWorkspaceTerminals(
workspace.directory,
preflight.sessions.map((session) => session.id),
platform,
context.sdk.scope,
)
context.sync.set(
"project",
produce((draft) => {
const project = draft.find((item) => item.id === workspace.project.id)
if (!project) return
project.sandboxes = (project.sandboxes ?? []).filter(
(directory) => pathKey(directory) !== pathKey(workspace.directory),
)
project.worktrees = project.worktrees.filter(
(worktree) => pathKey(worktree.directory) !== pathKey(workspace.directory),
)
}),
)
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
await projectQuery.refetch()
}
let inspectionID = 0
@@ -382,7 +383,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
<For each={linked()}>
{(session) => (
<div class="settings-v2-workspaces-session">
<span>{session.title}</span>
<span>{sessionLabel(session)}</span>
<Show when={sessionTime(session)}>
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
</Show>
@@ -1,17 +1,11 @@
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@/utils/toast"
import { createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createMemo, createResource, For, type JSXElement, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection, useServers } from "@/context/servers"
import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global"
import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { useWorkspaceLocation } from "@/context/location"
import { useData } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -25,110 +19,22 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
)
}
const listServersByHealth = (
list: ServerConnection.Any[],
active: ServerConnection.Key | undefined,
status: Record<ServerConnection.Key, ServerHealth | undefined>,
) => {
if (!list.length) return list
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (ServerConnection.key(a) === active) return -1
if (ServerConnection.key(b) === active) return 1
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
}
const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
const [state, setState] = createStore({
key: undefined as ServerConnection.Key | undefined,
tick: 0,
})
createEffect(() => {
state.tick
let dead = false
const result = get?.()
if (!result) {
setState("key", undefined)
onCleanup(() => {
dead = true
})
return
}
if (result instanceof Promise) {
void result.then((next) => {
if (dead) return
setState("key", next ?? undefined)
})
onCleanup(() => {
dead = true
})
return
}
setState("key", ServerConnection.Key.make(result))
onCleanup(() => {
dead = true
})
})
return {
key: () => {
return state.key
},
refresh: () => setState("tick", (value) => value + 1),
}
}
type ServerStatusItem = {
key: ServerConnection.Key
conn: ServerConnection.Any
health?: ServerHealth
blocked: boolean
active: boolean
onSelect: () => void
}
export function StatusPopoverBody(props: { shown: boolean }) {
const sync = useSync()
const sdk = useSDK()
const data = useData()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const language = useLanguage()
const fail = (err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
let dialogRun = 0
onCleanup(() => {
dialogRun += 1
})
const toggleMcp = useMcpToggle()
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
const toggleMcp = useMcpToggle(() => sdk().directory)
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? []
const mcpNames = createMemo(() => mcp().map((server) => server.name).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => mcp().find((server) => server.name === name)?.status.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync().data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const [pluginList] = createResource(
() => (props.shown ? sdk().directory : undefined),
(directory) =>
sdk()
.api.plugin.list({ location: { directory } })
serverSDK.api.plugin
.list({ location: { directory } })
.then((result) => result.data),
)
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
@@ -150,10 +56,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
{language.t("status.popover.tab.mcp")}
</Tabs.Trigger>
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
{lspCount() > 0 ? `${lspCount()} ` : ""}
{language.t("status.popover.tab.lsp")}
</Tabs.Trigger>
{/* TODO: Restore LSP status when V2 exposes it. */}
<Show when={true}>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
@@ -223,34 +126,6 @@ export function StatusPopoverBody(props: { shown: boolean }) {
</div>
</Tabs.Content>
<Tabs.Content value="lsp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={lspItems().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.lsp.empty")}</div>
}
>
<For each={lspItems()}>
{(item) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": item.status === "connected",
"bg-icon-critical-base": item.status === "error",
}}
/>
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Tabs.Content>
<Show when={true}>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
+16 -14
View File
@@ -5,16 +5,14 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerConnection, useServers } from "@/context/servers"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global"
import {
hasNonBlockingServiceIssue,
hasServiceNeedingAttention,
serverStatusDotClass,
} from "./status-popover-indicator"
import { useServer } from "@/context/server"
import { useData, useServer } from "@/context/server"
import { useWorkspaceLocation } from "@/context/location"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
@@ -22,19 +20,21 @@ export function StatusPopover() {
const language = useLanguage()
const server = useServer()
const global = useGlobal()
const sync = useSync()
const data = useData()
const sdk = useWorkspaceLocation()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
const ready = createMemo(() => serverHealth() === false || mcp() !== undefined)
const attention = createMemo(() =>
hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
mcp: (mcp() ?? []).map((item) => item.status.status),
}),
)
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status),
mcp: (mcp() ?? []).map((item) => item.status.status),
lsp: [],
}),
)
@@ -86,19 +86,21 @@ export function StatusPopoverV2() {
const language = useLanguage()
const server = useServer()
const global = useGlobal()
const sync = useSync()
const data = useData()
const sdk = useWorkspaceLocation()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
const ready = createMemo(() => serverHealth() === false || mcp() !== undefined)
const attention = createMemo(() =>
hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
mcp: (mcp() ?? []).map((item) => item.status.status),
}),
)
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
lsp: (sync().data.lsp ?? []).map((item) => item.status),
mcp: (mcp() ?? []).map((item) => item.status.status),
lsp: [],
}),
)
const state = createMemo<StatusPopoverState>(() => ({
+9 -7
View File
@@ -10,7 +10,8 @@ import { SerializeAddon } from "@/addons/serialize"
import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useWorkspaceLocation } from "@/context/location"
import { useServerSDK } from "@/context/server-sdk"
import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
@@ -171,13 +172,14 @@ const persistTerminal = (input: {
export const Terminal = (props: TerminalProps) => {
const platform = usePlatform()
const sdk = useSDK()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const settings = useSettings()
const theme = useTheme()
const language = useLanguage()
// Intentional mount-time capture: the imperative xterm/WebSocket lifecycle needs stable values, and Terminal remounts when the SDK scope changes.
const directory = sdk().directory
const url = sdk().url
const url = serverSDK.url
let container!: HTMLDivElement
const [local, others] = splitProps(props, [
"pty",
@@ -235,8 +237,8 @@ export const Terminal = (props: TerminalProps) => {
}
const pushSize = async (cols: number, rows: number) => {
return sdk()
.api.pty.update({
return serverSDK.api.pty
.update({
ptyID: id,
location: { directory },
size: { cols, rows },
@@ -519,8 +521,8 @@ export const Terminal = (props: TerminalProps) => {
}
const gone = async () => {
return sdk()
.api.pty.get({ ptyID: id, location: { directory } })
return serverSDK.api.pty
.get({ ptyID: id, location: { directory } })
.then((result) => result.data.status === "exited")
.catch((err) => {
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js"
import { createEffect, createMemo, createResource, For, onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
@@ -84,32 +84,35 @@ function SessionTabEntry(props: {
const tabs = useTabs()
const language = useLanguage()
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId))
const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource(
() => {
const ctx = props.serverCtx
return ctx ? { id: props.tab.sessionId, ctx } : null
},
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
({ id, ctx }) =>
ctx.data.session
.sync(id)
.then(() => ctx.data.session.get(id))
.catch(() => undefined),
)
const session = createMemo(() => cachedSession() ?? loadedSession())
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
let prefetched = false
const rename = async (title: string) => {
const value = session()
const ctx = props.serverCtx
if (!value || !ctx) return
ctx.sync.session.remember({ ...value, title })
ctx.data.session.remember({ ...value, title })
try {
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
if (current && currentCtx) currentCtx.data.session.remember({ ...current, title: value.title })
showToast({
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : undefined,
@@ -122,19 +125,19 @@ function SessionTabEntry(props: {
createEffect(() => {
const ctx = props.serverCtx
const value = session()
if (!ctx || !value || prefetched) return
prefetched = true
createRoot((dispose) => {
try {
void ctx.sync
.ensureDirSyncContext(value.location.directory)
.session.sync(value.id)
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
if (!ctx || !value || props.active || ctx.sdk.connection.status() !== "connected") return
const timer = window.setTimeout(
() =>
void Promise.allSettled([
ctx.data.session.sync(value.id, { children: true }),
ctx.data.session.pending.sync(value.id),
ctx.data.session.message.sync(value.id),
ctx.data.session.permission.sync(value.id),
ctx.data.session.form.sync(value.id),
]),
300 + props.index * 50,
)
onCleanup(() => window.clearTimeout(timer))
})
createEffect(() => {
+9 -8
View File
@@ -254,13 +254,14 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}
case "home": {
const selection = layout.home.selection()
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
const project = conn
? global
.ensureServerCtx(conn)
.projects.list()
.find((item) => item.worktree === selection.directory)
: undefined
const conn =
global.servers.list().find((item) => ServerConnection.key(item) === selection.server) ??
global.servers.list()[0]
const projects = conn ? global.ensureServerCtx(conn).projects : undefined
const project =
projects?.list().find((item) => item.worktree === selection.directory) ??
projects?.list().find((item) => item.worktree === projects.last()) ??
projects?.list()[0]
if (conn && project) {
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
return
@@ -327,7 +328,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
>
<ChannelIndicator debugTools={props.debugTools} />
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} variant="v2" />
<WindowsAppMenu command={command} platform={platform} />
</Show>
<TooltipV2
placement="bottom"
@@ -1,7 +1,6 @@
import { For, Show, type JSX } from "solid-js"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
@@ -13,7 +12,6 @@ import { useLanguage } from "@/context/language"
export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand>
platform: ReturnType<typeof usePlatform>
variant?: "legacy" | "v2"
}) {
let lastFocused: HTMLElement | undefined
const language = useLanguage()
@@ -50,35 +48,20 @@ export function WindowsAppMenu(props: {
return (
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
<Show
when={props.variant === "v2"}
fallback={
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
}
<div
data-component="desktop-icon-button"
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
>
<div
data-component="desktop-icon-button"
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
>
<DropdownMenu.Trigger
as={IconButtonV2}
variant="ghost-muted"
size="large"
icon={<IconV2 name="menu" />}
aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
</div>
</Show>
<DropdownMenu.Trigger
as={IconButtonV2}
variant="ghost-muted"
size="large"
icon={<IconV2 name="menu" />}
aria-label={language.t("desktop.menu.ariaLabel")}
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
</div>
<DropdownMenu.Portal>
<DropdownMenu.Content class="desktop-app-menu">
<DropdownMenu.Group>
+3 -5
View File
@@ -9,7 +9,7 @@ import type { ServerScope } from "@/utils/server-scope"
import { createScopedCache } from "@/utils/scoped-cache"
import { uuid } from "@/utils/uuid"
import type { SelectedLineRange } from "@/context/file"
import { useSDK } from "./sdk"
import { useWorkspaceLocation } from "./location"
export type LineComment = {
id: string
@@ -179,10 +179,8 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
}
function createCommentSession(scope: ServerScope, dir: string, id: string | undefined) {
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
const [store, setStore, _, ready] = persisted(
Persist.serverScoped(scope, dir, id, "comments", [legacy]),
Persist.serverScoped(scope, dir, id, "comments"),
createStore<CommentStore>({
comments: {},
}),
@@ -212,7 +210,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false,
init: () => {
const params = useParams()
const sdk = useSDK()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const cache = createScopedCache(
(key) => {
-127
View File
@@ -1,127 +0,0 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { SessionInboxInfo, SessionInfo } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk"
import type { createServerSyncContextInner } from "./server-sync"
import type { PromptEcho } from "./server-session"
import type { State } from "./global-sync/types"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const sessionFields = new Set([
"session_status",
"session_working",
"session_diff",
"todo",
"permission",
"question",
"message",
"session_message",
"part",
"part_text_accum_delta",
])
export const createDirSyncContext = (
directory: string,
serverSync: ReturnType<typeof createServerSyncContextInner>,
serverSDK: ReturnType<typeof createServerSdkContext>,
) => {
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const data = new Proxy({} as State, {
get(_, property: keyof State) {
if (property === "session_working") return serverSync.session.data.session_working.bind(serverSync.session.data)
if (sessionFields.has(property)) return serverSync.session.data[property as keyof typeof serverSync.session.data]
return current()[0][property]
},
})
const set = ((...input: unknown[]) => {
if (typeof input[0] === "string" && sessionFields.has(input[0])) {
return (serverSync.session.set as (...args: unknown[]) => unknown)(...input)
}
const result = (current()[1] as (...args: unknown[]) => unknown)(...input)
if (input[0] === "session") current()[0].session.forEach(serverSync.session.remember)
return result
}) as SetStoreFunction<State>
const index = (sessionID: string) => {
const session = serverSync.session.get(sessionID)
if (!session || session.location.directory !== directory) return
const [store, setStore] = current()
const result = Binary.search(store.session, session.id, (item) => item.id)
if (result.found) {
setStore("session", result.index, reconcile(session))
return
}
setStore(
"session",
produce((draft) => void draft.splice(result.index, 0, session)),
)
}
return {
data,
set,
get status() {
return current()[0].status
},
get ready() {
return current()[0].status !== "loading"
},
get project() {
const store = current()[0]
const match = Binary.search(serverSync.data.project, store.project, (project) => project.id)
if (match.found) return serverSync.data.project[match.index]
},
session: {
remember(session: SessionInfo) {
serverSync.session.remember(session)
index(session.id)
},
get(sessionID: string) {
const session = serverSync.session.get(sessionID)
if (session?.location.directory === directory) return session
},
inbox: {
echo(input: PromptEcho & { directory?: string }) {
serverSync.session.inbox.echo(input)
},
confirm(input: SessionInboxInfo) {
return serverSync.session.inbox.confirm(input)
},
clearEcho(input: { directory?: string; sessionID: string; messageID: string }) {
return serverSync.session.inbox.clearEcho(input)
},
},
async sync(sessionID: string, options?: { force?: boolean }) {
await serverSync.session.sync(sessionID, options)
index(sessionID)
},
todo: serverSync.session.todo,
history: serverSync.session.history,
evict(sessionID: string) {
serverSync.session.evict(sessionID)
},
fetch: async (count = 10) => {
const [store, setStore] = current()
setStore("limit", (value) => value + count)
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
const sessions = response.data.sort((a, b) => cmp(a.id, b.id)).slice(0, store.limit)
sessions.forEach(serverSync.session.remember)
setStore("session", reconcile(sessions, { key: "id" }))
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
// TODO: Restore archiving when the V2 client exposes a session archive API.
void sessionID
},
},
mcp: {
toggle: (name: string) => serverSync.mcp.toggle(directory, name),
},
absolute,
get directory() {
return current()[0].path.directory
},
}
}
+6 -8
View File
@@ -5,8 +5,7 @@ import { showToast } from "@/utils/toast"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { useWorkspaceLocation } from "./location"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { createPathHelpers } from "./file/path"
@@ -56,8 +55,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
name: "File",
gate: false,
init: () => {
const sdk = useSDK()
useSync()
const sdk = useWorkspaceLocation()
const params = useParams()
const serverSDK = useServerSDK()
const language = useLanguage()
@@ -80,8 +78,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
scope,
normalizeDir: path.normalizeDir,
list: (dir) =>
sdk()
.api.file.list({ path: dir, location: { directory: scope() } })
serverSDK.api.file
.list({ path: dir, location: { directory: scope() } })
.then((x) =>
x.data.map((entry) => ({
...entry,
@@ -187,8 +185,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file)
const promise = sdk()
.api.file.read({ path: file, location: { directory } })
const promise = serverSDK.api.file
.read({ path: file, location: { directory } })
.then((data) => {
if (scope() !== directory) return
const content = { type: "text" as const, content: new TextDecoder().decode(data) }

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