Compare commits

..

21 Commits

Author SHA1 Message Date
Aiden Cline f89f648d11 refactor(opencode): centralize question settlement 2026-05-25 01:12:40 -05:00
Aiden Cline c28ea79662 fix(opencode): settle interrupted question waits 2026-05-25 01:06:41 -05:00
Aiden Cline 08bef1696d fix(opencode): avoid caching missing question results 2026-05-25 00:59:09 -05:00
Aiden Cline 6186ae5e43 feat(opencode): add question ask and wait endpoints 2026-05-25 00:35:32 -05:00
James Long d5f397a2da fix(tui): open external editor in worktree cwd (#29130) 2026-05-24 22:40:40 -04:00
opencode-agent[bot] 5ca613e28e chore: update nix node_modules hashes 2026-05-25 02:28:11 +00:00
opencode-agent[bot] 9c7de47031 chore: generate 2026-05-25 02:06:42 +00:00
Luke Parker f023c63a60 fix(app): stabilize virtual session timeline interactions (#28422) 2026-05-25 12:05:27 +10:00
Brendan Allan 9495ecd536 refactor(app): extract refcount utility and clean up server sdk context (#29155) 2026-05-25 10:00:00 +08:00
Aiden Cline 03bb53c389 fix(tui): separate thinking header from markdown body (#29028) 2026-05-24 20:52:17 -05:00
Brendan Allan eab34b46d0 refactor(app): rename global context files to server context files (#28790) 2026-05-25 09:01:02 +08:00
Dax c5c9a1d435 fix(opencode): preserve session update time during project migration (#29147) 2026-05-24 19:31:19 -04:00
opencode-agent[bot] 47f333299b chore: generate 2026-05-24 18:08:39 +00:00
Aiden Cline db63eaf6ea tweak: make OPENCODE_EXPERIMENTAL_NATIVE_LLM separate from OPENCODE_EXPERIMENTAL (#29123) 2026-05-24 13:07:25 -05:00
opencode-agent[bot] 0cf99cf5f9 chore: update nix node_modules hashes 2026-05-24 02:24:16 +00:00
Aiden Cline d16bfe850a chore(opencode): bump amazon bedrock provider (#29052) 2026-05-23 21:06:00 -05:00
opencode-agent[bot] 728eae9c25 chore: generate 2026-05-24 01:08:00 +00:00
Aiden Cline eb84f461b8 fix(llm): split OpenAI reasoning summary blocks (#29000) 2026-05-23 20:06:45 -05:00
Kit Langton 0b3a1c2fdf test(opencode): simplify config effect tests (#29019) 2026-05-23 23:48:08 +00:00
opencode-agent[bot] 1ccd14b0e1 chore: update nix node_modules hashes 2026-05-23 20:51:28 +00:00
Aiden Cline 61e7cdfbff fix(opencode): bump google vertex provider for thought signatures (#29010)
Co-authored-by: Andrew Sweet <andrew.sweet@cantab.net>
2026-05-23 15:29:43 -05:00
138 changed files with 4425 additions and 2047 deletions
+39 -14
View File
@@ -199,15 +199,15 @@
},
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/amazon-bedrock": "4.0.107",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/google": "3.0.75",
"@ai-sdk/google-vertex": "4.0.131",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -409,15 +409,15 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.21.0",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/amazon-bedrock": "4.0.107",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/google": "3.0.75",
"@ai-sdk/google-vertex": "4.0.131",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -719,6 +719,7 @@
"patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
@@ -810,7 +811,7 @@
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.96", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@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-Mc4Ias2jRMD1jOB6xWtKNPdhECeuCZyIlbr9EAGfBnyBt++sS13ziZh9qv9TdyMCAZJ7xoQcpbchoRJcKwPdpA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@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-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
@@ -832,9 +833,9 @@
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.75", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XAm31ftiOrzlb8NjDzT7kw0xw+4lmgFdGFn1QKM73nXFFKyN1kWLESBV75UGNfjXP8X1YJ0YydnMVqO0jaPghw=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.131", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/google": "3.0.75", "@ai-sdk/openai-compatible": "2.0.47", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Oj1X8p0rVgvEoR5OOSxWi6XgzJ3QDlE/n30MZVtpKkCiToYYDyvlvVDGXz3IqhMyUev2JhlcuUk1brScKT01kA=="],
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
@@ -5090,9 +5091,13 @@
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
"@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=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
@@ -5108,11 +5113,17 @@
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="],
"@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/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
"@ai-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=="],
"@ai-sdk/google-vertex/@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/groq/@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=="],
@@ -6006,6 +6017,12 @@
"@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec/@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -6016,6 +6033,14 @@
"@ai-sdk/deepinfra/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/google-vertex/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
"@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/google/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
"@ai-sdk/groq/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-qq8cKNcW10NRbPx2T/Gpzby4nx2qHzj0LWDwqyPl7rY=",
"aarch64-linux": "sha256-UguK4nObAL/ovGZQousveoXCmkcxEgWy7XSSzxVJNEY=",
"aarch64-darwin": "sha256-NibSqZiF0XMbFhOtl2CQvFe4N/BXB5UAdt2iKv7dIcI=",
"x86_64-darwin": "sha256-ZNeZSpYitdUjXx/sULAIoLXrM6+Vz+GXQxKpdfgXh60="
"x86_64-linux": "sha256-Nol27sqG6za6oR32MqZrmRxch0/XFeQ61C9Yqtxo7Hk=",
"aarch64-linux": "sha256-+jfkGr4tuv/s/v890XaFTRqUxwA8H8KVMiys6sEXi/4=",
"aarch64-darwin": "sha256-FYtKluNazzuQcrr2iHm7B2pJfYrAHkbcj4kWs2FtBgc=",
"x86_64-darwin": "sha256-PpyWDkzb0p50US7Bs3m3A4PZ0JM3dccGgTKYN9JFYR0="
}
}
+1
View File
@@ -140,6 +140,7 @@
"@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",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch"
}
}
@@ -0,0 +1,395 @@
import { expect, test, type Locator, type Page, type Route } from "@playwright/test"
const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression"
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 title = "Timeline collapse state regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
declare global {
interface Window {
__timelineDiffProbe: {
reset: () => void
shadowRoots: () => number
}
}
}
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.",
},
],
}
const editPart = {
id: editPartID,
sessionID,
messageID: assistantMessageID,
type: "tool",
callID: "call_edit_regression",
tool: "edit",
state: {
status: "completed",
input: { filePath: "src/regression.ts" },
output: "Edited src/regression.ts",
title: "src/regression.ts",
metadata: {
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: "export const value = 'before'\n",
after: "export const value = 'after'\n",
},
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
},
time: { start: 1700000001000, end: 1700000002000 },
},
}
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],
}
test.describe("regression: session timeline local row state", () => {
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
const events: EventPayload[] = []
await mockServer(page, events)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible()
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expect(wrapper).toBeVisible()
await expectExpanded(wrapper, true)
await wrapper.evaluate((element) => {
;(element as HTMLElement).dataset.regressionMarker = "before-stream"
})
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
await expectExpanded(wrapper, false)
events.push({
directory,
payload: {
type: "message.part.updated",
properties: { part: streamedTextPart },
},
})
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
expect(await readToolState(page)).toEqual({
expanded: false,
row: "AssistantPart",
streamedTextVisible: true,
})
})
test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
const events: EventPayload[] = []
await installDiffProbe(page)
await mockServer(page, events)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible()
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expect(wrapper).toBeVisible()
await expect(wrapper.locator('[data-component="file"][data-mode="diff"]').first()).toBeVisible()
await markDiffProbe(page)
events.push({
directory,
payload: {
type: "message.part.updated",
properties: { part: streamedTextPart },
},
})
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", 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", shadowRoots: 0, toolMarker: "before" })
})
})
async function configurePage(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
}
async function expectExpanded(locator: Locator, expected: boolean) {
await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected)
}
async function readToolState(page: Page) {
return page
.locator(`[data-timeline-part-id="${editPartID}"]`)
.first()
.evaluate(
(element, textPartID) => ({
expanded: (() => {
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
const aria = trigger?.getAttribute("aria-expanded")
if (aria === "true") return true
if (aria === "false") return false
const root = element.querySelector('[data-component="collapsible"]')
if (root?.hasAttribute("data-expanded")) return true
if (root?.hasAttribute("data-closed")) return false
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
return !!content && content.getBoundingClientRect().height > 0
})(),
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
}),
textPartID,
)
}
async function installDiffProbe(page: Page) {
await page.addInitScript(() => {
let shadowRootCount = 0
const attachShadow = Element.prototype.attachShadow
Element.prototype.attachShadow = function (init) {
shadowRootCount += 1
return attachShadow.call(this, init)
}
window.__timelineDiffProbe = {
reset: () => {
shadowRootCount = 0
},
shadowRoots: () => shadowRootCount,
}
})
}
async function markDiffProbe(page: Page) {
await page
.locator(`[data-timeline-part-id="${editPartID}"]`)
.first()
.evaluate((element) => {
const tool = element as HTMLElement
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
if (!file) throw new Error("missing edit diff file")
tool.dataset.timelineProbe = "before"
file.dataset.timelineProbe = "before"
window.__timelineDiffProbe.reset()
})
}
async function readDiffProbe(page: Page) {
return page
.locator(`[data-timeline-part-id="${editPartID}"]`)
.first()
.evaluate((element) => {
const tool = element as HTMLElement
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
return {
fileMarker: file?.dataset.timelineProbe,
shadowRoots: window.__timelineDiffProbe.shadowRoots(),
toolMarker: tool.dataset.timelineProbe,
}
})
}
function editPartWithAdditions(additions: number) {
return {
...editPart,
state: {
...editPart.state,
metadata: {
...editPart.state.metadata,
filediff: {
...editPart.state.metadata.filediff,
additions,
},
},
},
}
}
function readExpanded(element: Element) {
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
const aria = trigger?.getAttribute("aria-expanded")
if (aria === "true") return true
if (aria === "false") return false
const root = element.querySelector('[data-component="collapsible"]')
if (root?.hasAttribute("data-expanded")) return true
if (root?.hasAttribute("data-closed")) return false
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
return !!content && content.getBoundingClientRect().height > 0
}
async function mockServer(page: Page, events: EventPayload[]) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
if (url.port !== targetPort) return route.fallback()
const path = url.pathname
if (path === "/global/event") return sse(route, events.splice(0))
if (
path === "/global/config" ||
path === "/config" ||
path === "/provider/auth" ||
path === "/mcp" ||
path === "/session/status"
)
return json(route, {})
if (
["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(
path,
)
)
return json(route, [])
if (path === "/provider") return json(route, provider())
if (path === "/path")
return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
if (path === "/project") return json(route, [project()])
if (path === "/project/current") return json(route, project())
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (path === "/session") return json(route, [session()])
if (path === `/session/${sessionID}`) return json(route, session())
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
if (path === `/session/${sessionID}/message`) return json(route, [userMessage, assistantMessage])
return json(route, {})
})
}
function project() {
return {
id: projectID,
worktree: directory,
vcs: "git",
name: "timeline-state-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
}
}
function session() {
return {
id: sessionID,
slug: "timeline-state-regression",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
}
}
function provider() {
return {
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" },
}
}
function json(route: Route, body: unknown, headers?: Record<string, string>) {
return route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route, events: EventPayload[]) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
headers: { "access-control-allow-origin": "*" },
body: events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
})
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
@@ -0,0 +1,300 @@
import { expect, test, type Page, type Route } from "@playwright/test"
const directory = "C:/OpenCode/ContextResizeRegression"
const projectID = "proj_context_resize_regression"
const sessionID = "ses_context_resize_regression"
const title = "Context resize regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
const followingTextID = "prt_0104_text"
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", () => {
test("remeasures a recent explored context group before the next paint", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockServer(page)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible()
await expect(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()).toBeVisible()
await settle(page)
const samples = await sampleExpansion(page)
const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
console.log("context resize samples", JSON.stringify(samples, null, 2))
expect(samples[0]?.overlap).toBe(0)
expect(visibleOverlap).toEqual([])
expect(samples.at(-1)?.expanded).toBe("true")
})
})
async function configurePage(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
}
async function sampleExpansion(page: Page) {
return page.evaluate(
({ contextIDs, followingTextID }) =>
new Promise<
{
frame: number
label: string
scrollTop: number
scrollHeight: number
contextBottom: number
textTop: number
overlap: number
gap: number
expanded: string | null
}[]
>((resolve) => {
const context = document.querySelector<HTMLElement>(`[data-timeline-part-ids="${contextIDs.join(",")}"]`)
const text = document.querySelector<HTMLElement>(`[data-timeline-part-id="${followingTextID}"]`)
const scroller = context?.closest<HTMLElement>(".scroll-view__viewport")
const trigger = context?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
const contextRow = context?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
const textRow = text?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
if (!context || !text || !scroller || !trigger || !contextRow || !textRow)
throw new Error("missing regression nodes")
scroller.scrollTop = scroller.scrollHeight
const samples: {
frame: number
label: string
scrollTop: number
scrollHeight: number
contextBottom: number
textTop: number
overlap: number
gap: number
expanded: string | null
}[] = []
const capture = (frame: number, label: string) => {
const contextRect = contextRow.getBoundingClientRect()
const textRect = textRow.getBoundingClientRect()
samples.push({
frame,
label,
scrollTop: Math.round(scroller.scrollTop * 10) / 10,
scrollHeight: Math.round(scroller.scrollHeight * 10) / 10,
contextBottom: Math.round(contextRect.bottom * 10) / 10,
textTop: Math.round(textRect.top * 10) / 10,
overlap: Math.max(0, Math.round((contextRect.bottom - textRect.top) * 10) / 10),
gap: Math.max(0, Math.round((textRect.top - contextRect.bottom) * 10) / 10),
expanded: trigger.getAttribute("aria-expanded"),
})
}
capture(-1, "before")
trigger.click()
capture(0, "sync-after-click")
let frame = 1
const tick = () => {
capture(frame, "raf")
frame += 1
if (frame > 8) {
resolve(samples)
return
}
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}),
{ contextIDs, followingTextID },
)
}
function turn(index: number, target: boolean): Message[] {
const userID = id("msg_user", index)
const assistantID = id("msg_assistant", index)
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}` }],
},
{
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 }),
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }),
contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }),
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }),
{
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)}`,
},
],
},
]
}
function contextTool(partID: string, messageID: string, tool: string, input: Record<string, unknown>) {
return {
id: partID,
sessionID,
messageID,
type: "tool",
callID: `call_${partID}`,
tool,
state: {
status: "completed",
input,
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
title: input.filePath || input.path || input.pattern || "completed",
metadata: {},
time: { start: 1700000000000, end: 1700000000100 },
},
}
}
async function mockServer(page: Page) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
if (url.port !== targetPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route)
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(path))
return json(route, {})
if (
["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/status", "/vcs/diff"].includes(
path,
)
)
return json(route, [])
if (path === "/provider") return json(route, provider())
if (path === "/path")
return json(route, { state: directory, config: directory, worktree: directory, directory, home: "C:/OpenCode" })
if (path === "/project") return json(route, [project()])
if (path === "/project/current") return json(route, project())
if (path === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (path === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (path === "/session") return json(route, [session()])
if (path === `/session/${sessionID}`) return json(route, session())
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
if (path === `/session/${sessionID}/message`) return json(route, messages)
return json(route, {})
})
}
async function settle(page: Page) {
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
}
function id(prefix: string, index: number) {
return `${prefix}_${String(index).padStart(4, "0")}`
}
function project() {
return {
id: projectID,
worktree: directory,
vcs: "git",
name: "context-resize-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
}
}
function session() {
return {
id: sessionID,
slug: "context-resize-regression",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
}
}
function provider() {
return {
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" },
}
}
function json(route: Route, body: unknown, headers?: Record<string, string>) {
return route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*", "access-control-expose-headers": "x-next-cursor", ...headers },
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
+6 -6
View File
@@ -30,8 +30,8 @@ import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { GlobalSyncProvider } from "@/context/global-sync"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
@@ -304,8 +304,8 @@ export function AppInterface(props: {
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<QueryProvider>
<GlobalSDKProvider>
<GlobalSyncProvider>
<ServerSDKProvider>
<ServerSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
@@ -316,8 +316,8 @@ export function AppInterface(props: {
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryProvider>
</ServerKey>
</ConnectionGate>
@@ -12,15 +12,15 @@ import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog()
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const language = useLanguage()
const providers = useProviders()
@@ -41,7 +41,7 @@ export function DialogConnectProvider(props: { provider: string }) {
})
const provider = createMemo(
() => providers.all().get(props.provider) ?? globalSync.data.provider.all.get(props.provider)!,
() => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!,
)
const fallback = createMemo<ProviderAuthMethod[]>(() => [
{
@@ -52,16 +52,16 @@ export function DialogConnectProvider(props: { provider: string }) {
const [auth] = createResource(
() => props.provider,
async () => {
const cached = globalSync.data.provider_auth[props.provider]
const cached = serverSync.data.provider_auth[props.provider]
if (cached) return cached
const res = await globalSDK.client.provider.auth()
const res = await serverSDK.client.provider.auth()
if (!alive.value) return fallback()
globalSync.set("provider_auth", res.data ?? {})
serverSync.set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
)
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback())
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
@@ -158,7 +158,7 @@ export function DialogConnectProvider(props: { provider: string }) {
}
dispatch({ type: "auth.pending" })
const start = Date.now()
await globalSDK.client.provider.oauth
await serverSDK.client.provider.oauth
.authorize(
{
providerID: props.provider,
@@ -330,7 +330,7 @@ export function DialogConnectProvider(props: { provider: string }) {
})
async function complete() {
await globalSDK.client.global.dispose()
await serverSDK.client.global.dispose()
dialog.close()
showToast({
variant: "success",
@@ -407,7 +407,7 @@ export function DialogConnectProvider(props: { provider: string }) {
}
setFormStore("error", undefined)
await globalSDK.client.auth.set({
await serverSDK.client.auth.set({
providerID: props.provider,
auth: {
type: "api",
@@ -478,7 +478,7 @@ export function DialogConnectProvider(props: { provider: string }) {
}
setFormStore("error", undefined)
const result = await globalSDK.client.provider.oauth
const result = await serverSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
@@ -531,7 +531,7 @@ export function DialogConnectProvider(props: { provider: string }) {
onMount(() => {
void (async () => {
const result = await globalSDK.client.provider.oauth
const result = await serverSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
@@ -9,8 +9,8 @@ import { showToast } from "@opencode-ai/ui/toast"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
import { DialogSelectProvider } from "./dialog-select-provider"
@@ -21,8 +21,8 @@ type Props = {
export function DialogCustomProvider(props: Props) {
const dialog = useDialog()
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const language = useLanguage()
const [form, setForm] = createStore<FormState>({
@@ -105,8 +105,8 @@ export function DialogCustomProvider(props: Props) {
const output = validateCustomProvider({
form,
t: language.t,
disabledProviders: globalSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(globalSync.data.provider.all.keys()),
disabledProviders: serverSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
})
batch(() => {
setForm("err", output.err)
@@ -118,11 +118,11 @@ export function DialogCustomProvider(props: Props) {
const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
const disabledProviders = globalSync.data.config.disabled_providers ?? []
const disabledProviders = serverSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
if (result.key) {
await globalSDK.client.auth.set({
await serverSDK.client.auth.set({
providerID: result.providerID,
auth: {
type: "api",
@@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
})
}
await globalSync.updateConfig({
await serverSync.updateConfig({
provider: { [result.providerID]: result.config },
disabled_providers: nextDisabled,
})
@@ -6,8 +6,8 @@ import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/core/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
@@ -18,8 +18,8 @@ const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] a
export function DialogEditProject(props: { project: LocalProject }) {
const dialog = useDialog()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const language = useLanguage()
const folderName = createMemo(() => getFilename(props.project.worktree))
@@ -78,19 +78,19 @@ export function DialogEditProject(props: { project: LocalProject }) {
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
await serverSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconOverride || undefined)
serverSync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
serverSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
@@ -6,8 +6,8 @@ import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout"
import { useLanguage } from "@/context/language"
@@ -128,7 +128,7 @@ function uniqueRows(rows: Row[]) {
}
function useDirectorySearch(args: {
sdk: ReturnType<typeof useGlobalSDK>
sdk: ReturnType<typeof useServerSDK>
start: () => string | undefined
home: () => string
}) {
@@ -246,8 +246,8 @@ function useDirectorySearch(args: {
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useGlobalSync()
const sdk = useGlobalSDK()
const sync = useServerSync()
const sdk = useServerSDK()
const layout = useLayout()
const dialog = useDialog()
const language = useLanguage()
@@ -9,8 +9,8 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
@@ -175,7 +175,7 @@ function createFileEntries(props: {
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
globalSDK: ReturnType<typeof useGlobalSDK>
serverSDK: ReturnType<typeof useServerSDK>
language: ReturnType<typeof useLanguage>
}) {
const state: {
@@ -207,7 +207,7 @@ function createSessionEntries(props: {
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props.globalSDK.client.session
return props.serverSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
@@ -268,8 +268,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const { params, tabs, view } = useSessionLayout()
const filesOnly = () => props.mode === "files"
const state = { cleanup: undefined as (() => void) | void, committed: false }
@@ -292,21 +292,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => globalSync.data.path.home)
const homedir = createMemo(() => serverSync.data.path.home)
const label = (directory: string) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = globalSync.child(directory, { bootstrap: false })
const [store] = serverSync.child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
}
const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language })
const items = async (text: string) => {
const query = text.trim()
@@ -6,7 +6,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language"
import { useQueryOptions } from "@/context/global-sync"
import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
const statusLabels = {
+1 -1
View File
@@ -66,7 +66,7 @@ import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/global-sync"
import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { getFilename } from "@opencode-ai/core/util/path"
@@ -162,8 +162,8 @@ beforeAll(async () => {
}),
}))
mock.module("@/context/global-sync", () => ({
useGlobalSync: () => ({
mock.module("@/context/server-sync", () => ({
useServerSync: () => ({
child: (directory: string) => {
syncedDirectories.push(directory)
storedSessions[directory] ??= []
@@ -5,7 +5,7 @@ import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useGlobalSync } from "@/context/global-sync"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
@@ -38,7 +38,7 @@ export type FollowupDraft = {
type FollowupSendInput = {
client: ReturnType<typeof useSDK>["client"]
globalSync: ReturnType<typeof useGlobalSync>
serverSync: ReturnType<typeof useServerSync>
sync: ReturnType<typeof useSync>
draft: FollowupDraft
messageID?: string
@@ -53,7 +53,7 @@ const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttac
export async function sendFollowupDraft(input: FollowupSendInput) {
const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt)
const [, setStore] = input.globalSync.child(input.draft.sessionDirectory)
const [, setStore] = input.serverSync.child(input.draft.sessionDirectory)
const setBusy = () => {
if (!input.optimisticBusy) return
@@ -205,7 +205,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
const serverSync = useServerSync()
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
@@ -226,8 +226,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
globalSync.todo.set(sessionID, [])
const [, setStore] = globalSync.child(sdk.directory)
serverSync.todo.set(sessionID, [])
const [, setStore] = serverSync.child(sdk.directory)
setStore("todo", sessionID, [])
input.onAbort?.()
@@ -273,7 +273,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
const seed = (dir: string, info: Session) => {
const [, setStore] = globalSync.child(dir)
const [, setStore] = serverSync.child(dir)
setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
@@ -354,7 +354,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
directory: sessionDirectory,
throwOnError: true,
})
globalSync.child(sessionDirectory)
serverSync.child(sessionDirectory)
}
input.onNewSessionWorktreeReset?.()
@@ -557,7 +557,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
void sendFollowupDraft({
client,
sync,
globalSync,
serverSync,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
@@ -1,7 +1,7 @@
import type { JSX } from "solid-js"
import { createMemo } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { useGlobalSync } from "@/context/global-sync"
import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
@@ -15,7 +15,7 @@ import { WordmarkV2 } from "@opencode-ai/ui/v2/components/wordmark-v2.jsx"
const MAIN_WORKTREE = "main"
export function NewSessionDesignView(props: { worktree: string; children: JSX.Element }) {
const globalSync = useGlobalSync()
const serverSync = useServerSync()
const layout = useLayout()
const navigate = useNavigate()
const sdk = useSDK()
@@ -24,7 +24,7 @@ export function NewSessionDesignView(props: { worktree: string; children: JSX.El
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
const projects = createMemo(() => {
const roots = globalSync.data.project.map((project) => project.worktree)
const roots = serverSync.data.project.map((project) => project.worktree)
if (roots.includes(projectRoot())) return roots
return [projectRoot(), ...roots]
})
@@ -12,8 +12,8 @@ import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useGlobalSync } from "@/context/global-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import {
monoDefault,
monoFontFamily,
@@ -175,8 +175,8 @@ export const SettingsGeneral: Component = () => {
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const globalSync = useGlobalSync()
const globalSdk = useGlobalSDK()
const serverSync = useServerSync()
const globalSdk = useServerSDK()
const [shells] = createResource(
() =>
@@ -204,11 +204,11 @@ export const SettingsGeneral: Component = () => {
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = globalSync.data.config.shell
const current = serverSync.data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
@@ -343,7 +343,7 @@ export const SettingsGeneral: Component = () => {
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
globalSync.updateConfig({ shell: option.value })
serverSync.updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"
@@ -6,8 +6,8 @@ import { showToast } from "@opencode-ai/ui/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
@@ -30,8 +30,8 @@ const PROVIDER_NOTES = [
export const SettingsProviders: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders()
const connected = createMemo(() => {
@@ -74,7 +74,7 @@ export const SettingsProviders: Component = () => {
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = globalSync.data.config.provider?.[providerID]
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
@@ -82,11 +82,11 @@ export const SettingsProviders: Component = () => {
}
const disableProvider = async (providerID: string, name: string) => {
const before = globalSync.data.config.disabled_providers ?? []
const before = serverSync.data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
globalSync.set("config", "disabled_providers", next)
serverSync.set("config", "disabled_providers", next)
await globalSync
await serverSync
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
@@ -97,7 +97,7 @@ export const SettingsProviders: Component = () => {
})
})
.catch((err: unknown) => {
globalSync.set("config", "disabled_providers", before)
serverSync.set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
@@ -105,14 +105,14 @@ export const SettingsProviders: Component = () => {
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
await serverSDK.client.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
return
}
await globalSDK.client.auth
await serverSDK.client.auth
.remove({ providerID })
.then(async () => {
await globalSDK.client.global.dispose()
await serverSDK.client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",
@@ -15,7 +15,7 @@ import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { useQueryOptions } from "@/context/global-sync"
import { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
const pollMs = 10_000
+3 -3
View File
@@ -16,7 +16,7 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useGlobalSync } from "@/context/global-sync"
import { useServerSync } from "@/context/server-sync"
import { decodeDirectory } from "@/pages/directory-layout"
import { iife } from "@opencode-ai/core/util/iife"
import { base64Encode } from "@opencode-ai/core/util/encode"
@@ -221,7 +221,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Switch>
<Match when={USE_V2_TITLEBAR}>
{(_) => {
const globalSync = useGlobalSync()
const serverSync = useServerSync()
const navigate = useNavigate()
const homeMatch = useMatch(() => "/")
@@ -391,7 +391,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const base = mapArray(
() => tabsStore,
(tab) => {
const sync = globalSync.createDirSyncContext(tab.dir)
const sync = serverSync.createDirSyncContext(tab.dir)
const session = sync.session.get(tab.sessionId)
return session ? { ...tab, info: session } : null
},
+23 -21
View File
@@ -8,10 +8,11 @@ import {
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { useGlobalSync } from "./global-sync"
import { createServerSyncContext, useServerSync } from "./server-sync"
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
import { useServerSDK } from "./server-sdk"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -164,16 +165,17 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
})
}
export const createDirSyncContext = (client: OpencodeClient, directory: string) => {
const globalSync = useGlobalSync()
export const createDirSyncContext = (directory: string, serverSync: ReturnType<typeof createServerSyncContext>) => {
const serverSDK = useServerSDK()
const client = serverSDK.createClient({ directory, throwOnError: true })
type Child = ReturnType<(typeof globalSync)["child"]>
type Child = ReturnType<(typeof serverSync)["child"]>
type Setter = Child[1]
const current = createMemo(() => globalSync.child(directory))
const current = createMemo(() => serverSync.child(directory))
const target = (directory?: string) => {
if (!directory || directory === directory) return current()
return globalSync.child(directory)
return serverSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
@@ -239,7 +241,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = globalSync.child(first, { bootstrap: false })
const [, setStore] = serverSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
@@ -267,7 +269,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) {
globalSync.todo.set(sessionID, undefined)
serverSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
@@ -324,7 +326,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = globalSync.child(input.directory, { bootstrap: false })
const [store] = serverSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
@@ -373,8 +375,8 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
},
get project() {
const store = current()[0]
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
if (match.found) return globalSync.data.project[match.index]
const match = Binary.search(serverSync.data.project, store.project, (p) => p.id)
if (match.found) return serverSync.data.project[match.index]
return undefined
},
session: {
@@ -418,7 +420,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
})
},
async sync(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = globalSync.child(directory)
const [store, setStore] = serverSync.child(directory)
const key = keyFor(directory, sessionID)
touch(directory, setStore, sessionID)
@@ -488,7 +490,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
})
},
async diff(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = globalSync.child(directory)
const [store, setStore] = serverSync.child(directory)
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
@@ -501,13 +503,13 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
)
},
async todo(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = globalSync.child(directory)
const [store, setStore] = serverSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
const cached = serverSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (cached === undefined) {
globalSync.todo.set(sessionID, existing)
serverSync.todo.set(sessionID, existing)
}
if (!opts?.force) return
}
@@ -522,7 +524,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
serverSync.todo.set(sessionID, list)
}),
)
},
@@ -540,7 +542,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count?: number) {
const [, setStore] = globalSync.child(directory)
const [, setStore] = serverSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize
@@ -561,12 +563,12 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
},
},
evict(sessionID: string, _directory = directory) {
const [, setStore] = globalSync.child(_directory)
const [, setStore] = serverSync.child(_directory)
seenFor(_directory).delete(sessionID)
evict(_directory, setStore, [sessionID])
},
fetch: async (count = 10) => {
const [store, setStore] = globalSync.child(directory)
const [store, setStore] = serverSync.child(directory)
setStore("limit", (x) => x + count)
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
@@ -578,7 +580,7 @@ export const createDirSyncContext = (client: OpencodeClient, directory: string)
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
const [, setStore] = globalSync.child(directory)
const [, setStore] = serverSync.child(directory)
await client.session.update({ sessionID, time: { archived: Date.now() } })
setStore(
produce((draft) => {
-309
View File
@@ -1,309 +0,0 @@
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { batch, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { useServer } from "./server"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK",
init: () => {
const language = useLanguage()
const server = useServer()
const platform = usePlatform()
const abort = new AbortController()
const eventFetch = (() => {
if (!platform.fetch || !server.current) return
try {
const url = new URL(server.current.http.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch
} catch {
return
}
})()
const currentServer = server.current
if (!currentServer) throw new Error(language.t("error.globalSDK.noServerAvailable"))
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
server: currentServer.http,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
}>()
type Queued = { directory: string; payload: Event }
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
let queue: Queued[] = []
let buffer: Queued[] = []
const coalesced = new Map<string, number>()
const staleDeltas = new Set<string>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
if (queue.length === 0) return
const events = queue
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
queue = buffer
buffer = events
queue.length = 0
coalesced.clear()
staleDeltas.clear()
last = Date.now()
batch(() => {
for (const event of events) {
if (skip && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
}
emitter.emit(event.directory, event.payload)
}
})
buffer.length = 0
}
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
}
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const aborted = isAbortError
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}
const clearHeartbeat = () => {
if (!heartbeat) return
clearTimeout(heartbeat)
heartbeat = undefined
}
const start = () => {
if (started) return run
started = true
run = (async () => {
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
if (event.payload.type === "sync") {
continue
}
const payload = event.payload as Event
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
}
if (abort.signal.aborted || !started) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
run = undefined
flush()
})
return run
}
const stop = () => {
started = false
attempt?.abort()
clearHeartbeat()
}
onMount(() => {
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
})
})
onCleanup(() => {
stop()
abort.abort()
flush()
})
const sdk = createSdkForServer({
server: server.current.http,
fetch: platform.fetch,
throwOnError: true,
})
const dirSyncContexts = new Map<string, ReturnType<typeof createDirSdkContext>>()
const dirSdkContextRefCounts = new Map<string, number>()
return {
url: currentServer.http.url,
client: sdk,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
return createSdkForServer({
server: s.http,
fetch: platform.fetch,
...opts,
})
},
createDirSyncContext: (directory: string) => {
onCleanup(() => {
dirSdkContextRefCounts.set(directory, (dirSdkContextRefCounts.get(directory) ?? 0) - 1)
if (dirSdkContextRefCounts.get(directory) === 0) {
dirSyncContexts.delete(directory)
dirSdkContextRefCounts.delete(directory)
}
})
const cached = dirSyncContexts.get(directory)
if (cached) {
dirSdkContextRefCounts.set(directory, (dirSdkContextRefCounts.get(directory) ?? 0) + 1)
return cached
}
const ctx = createDirSdkContext(directory)
dirSyncContexts.set(directory, ctx)
dirSdkContextRefCounts.set(directory, 1)
return ctx
},
}
},
})
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
}
function createDirSdkContext(directory: string) {
const globalSDK = useGlobalSDK()
const client = globalSDK.createClient({
directory,
throwOnError: true,
})
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = globalSDK.event.on(directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
return {
directory,
client,
event: emitter,
get url() {
return globalSDK.url
},
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
return globalSDK.createClient(opts)
},
}
}
@@ -18,7 +18,7 @@ import type { State, VcsCache } from "./types"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery } from "../global-sync"
import { loadMcpQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
type GlobalStore = {
@@ -105,7 +105,7 @@ export const loadProjectsQuery = (sdk: OpencodeClient) =>
})
export async function bootstrapGlobal(input: {
globalSDK: OpencodeClient
serverSDK: OpencodeClient
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
@@ -113,12 +113,12 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient
}) {
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.globalSDK)),
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.globalSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(null, input.globalSDK)),
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.serverSDK)),
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(null, input.serverSDK)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.globalSDK))
.fetchQuery(loadProjectsQuery(input.serverSDK))
.then((data) => input.setGlobalStore("project", data)),
]
await runAll(slow)
@@ -3,7 +3,7 @@ import { createRoot, getOwner, type Owner } from "solid-js"
import { createStore } from "solid-js/store"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { State } from "./types"
import type { QueryOptionsApi } from "../global-sync"
import type { QueryOptionsApi } from "../server-sync"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
@@ -15,7 +15,7 @@ import {
} from "./types"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQueries } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../global-sync"
import { QueryOptionsApi } from "../server-sync"
import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
+13 -13
View File
@@ -2,8 +2,8 @@ import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk"
import { useServer } from "./server"
import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
@@ -136,8 +136,8 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
const globalSdk = useGlobalSDK()
const globalSync = useGlobalSync()
const globalSdk = useServerSDK()
const serverSync = useServerSync()
const server = useServer()
const platform = usePlatform()
@@ -386,11 +386,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = globalSync.child(project.worktree, { bootstrap: false })
const [childStore] = serverSync.child(project.worktree, { bootstrap: false })
const projectID = childStore.project
const metadata = projectID
? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree)
? serverSync.data.project.find((x) => x.id === projectID)
: serverSync.data.project.find((x) => x.worktree === project.worktree)
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
@@ -404,7 +404,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const roots = createMemo(() => {
const map = new Map<string, string>()
for (const project of globalSync.data.project) {
for (const project of serverSync.data.project) {
const sandboxes = project.sandboxes ?? []
for (const sandbox of sandboxes) {
map.set(sandbox, project.worktree)
@@ -470,12 +470,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
if (!globalSync.ready) return
if (!serverSync.ready) return
for (const project of projects) {
if (!project.id) continue
if (project.id === "global") continue
globalSync.project.icon(project.worktree, project.icon?.override)
serverSync.project.icon(project.worktree, project.icon?.override)
}
})
@@ -509,7 +509,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
colorRequested.set(worktree, color)
if (project.id === "global") {
globalSync.project.meta(worktree, { icon: { color } })
serverSync.project.meta(worktree, { icon: { color } })
continue
}
@@ -531,7 +531,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
sessionTimer = undefined
void Promise.all(
server.projects.list().map((project) => {
return globalSync.project.loadSessions(project.worktree)
return serverSync.project.loadSessions(project.worktree)
}),
)
}, 0)
@@ -560,7 +560,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
open(directory: string) {
const root = rootFor(directory)
if (server.projects.list().find((x) => x.worktree === root)) return
void globalSync.project.loadSessions(root)
void serverSync.project.loadSessions(root)
server.projects.open(root)
},
close(directory: string) {
+7 -7
View File
@@ -2,8 +2,8 @@ import { createStore, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
import { useGlobalSync } from "./global-sync"
import { useServerSDK } from "./server-sdk"
import { useServerSync } from "./server-sync"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
@@ -109,8 +109,8 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
name: "Notification",
init: () => {
const params = useParams()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
@@ -207,10 +207,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const [syncStore] = globalSync.child(directory, { bootstrap: false })
const [syncStore] = serverSync.child(directory, { bootstrap: false })
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
if (match.found) return syncStore.session[match.index]
return globalSDK.client.session
return serverSDK.client.session
.get({ directory, sessionID })
.then((x) => x.data)
.catch(() => undefined)
@@ -285,7 +285,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
})
}
const unsub = globalSDK.event.listen((e) => {
const unsub = serverSDK.event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
+13 -13
View File
@@ -3,8 +3,8 @@ import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { Persist, persisted } from "@/utils/persist"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "./server-sync"
import { useParams } from "@solidjs/router"
import { decode64 } from "@/utils/base64"
import {
@@ -48,13 +48,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
name: "Permission",
init: () => {
const params = useParams()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const permissionsEnabled = createMemo(() => {
const directory = decode64(params.dir)
if (!directory) return false
const [store] = globalSync.child(directory)
const [store] = serverSync.child(directory)
return hasPermissionPromptRules(store.config.permission)
})
@@ -86,7 +86,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
if (!ready()) return
const directory = decode64(params.dir)
if (!directory) return
const [childStore] = globalSync.child(directory)
const [childStore] = serverSync.child(directory)
const perm = childStore.config.permission
if (typeof perm === "string" && perm === "allow") {
const key = directoryAcceptKey(directory)
@@ -118,7 +118,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
const respond: PermissionRespondFn = (input) => {
globalSDK.client.permission.respond(input).catch(() => {
serverSDK.client.permission.respond(input).catch(() => {
responded.delete(input.permissionID)
})
}
@@ -139,7 +139,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
function isAutoAccepting(sessionID: string, directory?: string) {
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
}
@@ -148,7 +148,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, permission, directory)
}
@@ -159,7 +159,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return next
}
const unsubscribe = globalSDK.event.listen((e) => {
const unsubscribe = serverSDK.event.listen((e) => {
const event = e.details
if (event?.type !== "permission.asked") return
@@ -178,7 +178,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}),
)
globalSDK.client.permission
serverSDK.client.permission
.list({ directory })
.then((x) => {
if (!isAutoAcceptingDirectory(directory)) return
@@ -210,7 +210,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}),
)
globalSDK.client.permission
serverSDK.client.permission
.list({ directory })
.then((x) => {
if (enableVersion.get(key) !== version) return
@@ -268,7 +268,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
},
permissionsEnabled,
isPermissionAllowAll(directory: string) {
const [childStore] = globalSync.child(directory)
const [childStore] = serverSync.child(directory)
const perm = childStore.config.permission
return typeof perm === "string" && perm === "allow"
},
+3 -3
View File
@@ -1,11 +1,11 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
import { useServerSDK } from "./server-sdk"
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { directory: string }) => {
const globalSDK = useGlobalSDK()
const serverSDK = useServerSDK()
return globalSDK.createDirSyncContext(props.directory)
return serverSDK.createDirSdkContext(props.directory)
},
})
+290
View File
@@ -0,0 +1,290 @@
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { batch, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
function createServerSdkContext(server: ServerConnection.Any) {
const platform = usePlatform()
const abort = new AbortController()
const eventFetch = (() => {
if (!platform.fetch || !server) return
try {
const url = new URL(server.http.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch
} catch {
return
}
})()
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
server: server.http,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
}>()
type Queued = { directory: string; payload: Event }
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
let queue: Queued[] = []
let buffer: Queued[] = []
const coalesced = new Map<string, number>()
const staleDeltas = new Set<string>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
if (queue.length === 0) return
const events = queue
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
queue = buffer
buffer = events
queue.length = 0
coalesced.clear()
staleDeltas.clear()
last = Date.now()
batch(() => {
for (const event of events) {
if (skip && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
}
emitter.emit(event.directory, event.payload)
}
})
buffer.length = 0
}
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
}
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const aborted = isAbortError
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}
const clearHeartbeat = () => {
if (!heartbeat) return
clearTimeout(heartbeat)
heartbeat = undefined
}
const start = () => {
if (started) return run
started = true
run = (async () => {
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
if (event.payload.type === "sync") {
continue
}
const payload = event.payload as Event
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
}
if (abort.signal.aborted || !started) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
run = undefined
flush()
})
return run
}
const stop = () => {
started = false
attempt?.abort()
clearHeartbeat()
}
onMount(() => {
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
})
})
onCleanup(() => {
stop()
abort.abort()
flush()
})
const sdk = createSdkForServer({
server: server.http,
fetch: platform.fetch,
throwOnError: true,
})
return {
url: server.http.url,
client: sdk,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
return createSdkForServer({
server: server.http,
fetch: platform.fetch,
...opts,
})
},
}
}
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
name: "ServerSDK",
init: () => {
const language = useLanguage()
const server = useServer()
if (!server.current) throw new Error(language.t("error.serverSDK.noServerAvailable"))
const sdk = createServerSdkContext(server.current)
return {
...sdk,
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
}
},
})
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
}
function createDirSdkContext(directory: string, serverSDK: ReturnType<typeof createServerSdkContext>) {
const client = serverSDK.createClient({
directory,
throwOnError: true,
})
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.event.on(directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
return {
directory,
client,
event: emitter,
get url() {
return serverSDK.url
},
createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
return serverSDK.createClient(opts)
},
}
}
@@ -5,7 +5,7 @@ import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, u
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk"
import { useServerSDK } from "./server-sdk"
import {
bootstrapDirectory,
bootstrapGlobal,
@@ -29,7 +29,8 @@ import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { createRefCountMap } from "@/utils/refcount"
type GlobalStore = {
ready: boolean
@@ -57,13 +58,13 @@ export const loadLspQuery = (directory: string, sdk: OpencodeClient) =>
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
})
function makeQueryOptionsApi(globalSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
return {
globalConfig: () => loadGlobalConfigQuery(globalSDK()),
projects: () => loadProjectsQuery(globalSDK()),
globalConfig: () => loadGlobalConfigQuery(serverSDK()),
projects: () => loadProjectsQuery(serverSDK()),
providers: (directory: PathKey | null) =>
loadProvidersQuery(directory, directory === null ? globalSDK() : sdkFor(directory)),
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? globalSDK() : sdkFor(directory)),
loadProvidersQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)),
@@ -72,11 +73,11 @@ function makeQueryOptionsApi(globalSDK: () => OpencodeClient, sdkFor: (dir: Path
}
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
function createGlobalSync() {
const globalSDK = useGlobalSDK()
export function createServerSyncContext() {
const serverSDK = useServerSDK()
const language = useLanguage()
const owner = getOwner()
if (!owner) throw new Error("GlobalSync must be created within owner")
if (!owner) throw new Error("ServerSync must be created within owner")
const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>()
@@ -87,7 +88,7 @@ function createGlobalSync() {
const key = directoryKey(directory)
const cached = sdkCache.get(key)
if (cached) return cached
const sdk = globalSDK.createClient({
const sdk = serverSDK.createClient({
directory,
throwOnError: true,
})
@@ -95,7 +96,7 @@ function createGlobalSync() {
return sdk
}
const queryOptionsApi = makeQueryOptionsApi(() => globalSDK.client, sdkFor)
const queryOptionsApi = makeQueryOptionsApi(() => serverSDK.client, sdkFor)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
@@ -154,7 +155,7 @@ function createGlobalSync() {
queryKey: ["bootstrap"],
queryFn: async () => {
await bootstrapGlobal({
globalSDK: globalSDK.client,
serverSDK: serverSDK.client,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
@@ -248,7 +249,7 @@ function createGlobalSync() {
loadRootSessionsWithFallback({
directory,
limit,
list: (query) => globalSDK.client.session.list(query),
list: (query) => serverSDK.client.session.list(query),
})
.then((x) => {
const nonArchived = (x.data ?? [])
@@ -334,7 +335,7 @@ function createGlobalSync() {
return promise
}
const unsub = globalSDK.event.listen((e) => {
const unsub = serverSDK.event.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
@@ -393,13 +394,13 @@ function createGlobalSync() {
eventFrame = undefined
eventTimer = setTimeout(() => {
eventTimer = undefined
void globalSDK.event.start()
void serverSDK.event.start()
}, 0)
})
} else {
eventTimer = setTimeout(() => {
eventTimer = undefined
void globalSDK.event.start()
void serverSDK.event.start()
}, 0)
}
})
@@ -415,7 +416,7 @@ function createGlobalSync() {
}
const updateConfigMutation = useMutation(() => ({
mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }),
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
onSuccess: () => {
bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers
@@ -425,9 +426,6 @@ function createGlobalSync() {
},
}))
const dirSyncContexts = new Map<string, ReturnType<typeof createDirSyncContext>>()
const dirSyncContextRefCounts = new Map<string, number>()
return {
data: globalStore,
set,
@@ -446,42 +444,21 @@ function createGlobalSync() {
todo: {
set: setSessionTodo,
},
createDirSyncContext: (directory: string) => {
onCleanup(() => {
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) - 1)
if (dirSyncContextRefCounts.get(directory) === 0) {
dirSyncContexts.delete(directory)
dirSyncContextRefCounts.delete(directory)
}
})
const cached = dirSyncContexts.get(directory)
if (cached) {
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) + 1)
return cached
}
const ctx = createDirSyncContext(globalSDK.createClient({ directory, throwOnError: true }), directory)
dirSyncContexts.set(directory, ctx)
dirSyncContextRefCounts.set(directory, 1)
return ctx
},
}
}
const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
name: "ServerSync",
init: () => {
const sync = createServerSyncContext()
export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync()
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
}
export function useGlobalSync() {
const context = useContext(GlobalSyncContext)
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
return context
}
return {
...sync,
createDirSyncContext: createRefCountMap((dir) => createDirSyncContext(dir, sync)),
}
},
})
export function useQueryOptions() {
return useGlobalSync().queryOptions
return useServerSync().queryOptions
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { useGlobalSync } from "./global-sync"
import { useServerSync } from "./server-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
@@ -109,8 +109,8 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR
}
export const useSync = () => {
const globalSync = useGlobalSync()
const serverSync = useServerSync()
const sdk = useSDK()
return globalSync.createDirSyncContext(sdk.directory)
return serverSync.createDirSyncContext(sdk.directory)
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { useGlobalSync } from "@/context/global-sync"
import { useServerSync } from "@/context/server-sync"
import { decode64 } from "@/utils/base64"
import { useParams } from "@solidjs/router"
import { Iterable, pipe } from "effect"
@@ -17,15 +17,15 @@ export const popularProviders = [
const popularProviderSet = new Set(popularProviders)
export function useProviders() {
const globalSync = useGlobalSync()
const serverSync = useServerSync()
const params = useParams()
const dir = createMemo(() => decode64(params.dir) ?? "")
const providers = () => {
if (dir()) {
const [projectStore] = globalSync.child(dir())
const [projectStore] = serverSync.child(dir())
if (projectStore.provider_ready) return projectStore.provider
}
return globalSync.data.provider
return serverSync.data.provider
}
return {
all: () => providers().all,
+3 -3
View File
@@ -413,7 +413,7 @@ export const dict = {
"error.page.version": "الإصدار: {{version}}",
"error.dev.rootNotFound":
"لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟",
"error.globalSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟",
"error.serverSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟",
"directory.error.invalidUrl": "دليل غير صالح في عنوان URL.",
"error.chain.unknown": "خطأ غير معروف",
"error.chain.causedBy": "بسبب:",
@@ -837,8 +837,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "غير معروف",
"error.page.circular": "[دائري]",
"error.globalSDK.noServerAvailable": "لا يوجد خادم متاح",
"error.globalSDK.serverNotAvailable": "الخادم غير متاح",
"error.serverSDK.noServerAvailable": "لا يوجد خادم متاح",
"error.serverSDK.serverNotAvailable": "الخادم غير متاح",
"error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة",
"error.childStore.persistedProjectMetadataCreateFailed": "فشل إنشاء بيانات تعريف المشروع الدائمة",
"error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة",
+3 -3
View File
@@ -414,7 +414,7 @@ export const dict = {
"error.page.version": "Versão: {{version}}",
"error.dev.rootNotFound":
"Elemento raiz não encontrado. Você esqueceu de adicioná-lo ao seu index.html? Ou talvez o atributo id foi escrito incorretamente?",
"error.globalSync.connectFailed": "Não foi possível conectar ao servidor. Há um servidor executando em `{{url}}`?",
"error.serverSync.connectFailed": "Não foi possível conectar ao servidor. Há um servidor executando em `{{url}}`?",
"directory.error.invalidUrl": "Diretório inválido na URL.",
"error.chain.unknown": "Erro desconhecido",
"error.chain.causedBy": "Causado por:",
@@ -850,8 +850,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "desconhecido",
"error.page.circular": "[Circular]",
"error.globalSDK.noServerAvailable": "Nenhum servidor disponível",
"error.globalSDK.serverNotAvailable": "Servidor indisponível",
"error.serverSDK.noServerAvailable": "Nenhum servidor disponível",
"error.serverSDK.serverNotAvailable": "Servidor indisponível",
"error.childStore.persistedCacheCreateFailed": "Falha ao criar cache persistente",
"error.childStore.persistedProjectMetadataCreateFailed": "Falha ao criar metadados de projeto persistentes",
"error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente",
+3 -3
View File
@@ -462,7 +462,7 @@ export const dict = {
"error.dev.rootNotFound":
"Korijenski element nije pronađen. Da li si zaboravio da ga dodaš u index.html? Ili je možda id atribut pogrešno napisan?",
"error.globalSync.connectFailed": "Nije moguće povezati se na server. Da li server radi na `{{url}}`?",
"error.serverSync.connectFailed": "Nije moguće povezati se na server. Da li server radi na `{{url}}`?",
"directory.error.invalidUrl": "Nevažeći direktorij u URL-u.",
"error.chain.unknown": "Nepoznata greška",
@@ -926,8 +926,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "nepoznato",
"error.page.circular": "[Kružno]",
"error.globalSDK.noServerAvailable": "Nema dostupnog servera",
"error.globalSDK.serverNotAvailable": "Server nije dostupan",
"error.serverSDK.noServerAvailable": "Nema dostupnog servera",
"error.serverSDK.serverNotAvailable": "Server nije dostupan",
"error.childStore.persistedCacheCreateFailed": "Nije uspjelo kreiranje trajnog keša",
"error.childStore.persistedProjectMetadataCreateFailed": "Nije uspjelo kreiranje trajnih metapodataka projekta",
"error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta",
+3 -3
View File
@@ -459,7 +459,7 @@ export const dict = {
"error.dev.rootNotFound":
"Rodelement ikke fundet. Har du glemt at tilføje det til din index.html? Eller måske er id-attributten stavet forkert?",
"error.globalSync.connectFailed": "Kunne ikke forbinde til server. Kører der en server på `{{url}}`?",
"error.serverSync.connectFailed": "Kunne ikke forbinde til server. Kører der en server på `{{url}}`?",
"directory.error.invalidUrl": "Ugyldig mappe i URL.",
"error.chain.unknown": "Ukendt fejl",
@@ -920,8 +920,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "ukendt",
"error.page.circular": "[Cirkulær]",
"error.globalSDK.noServerAvailable": "Ingen server tilgængelig",
"error.globalSDK.serverNotAvailable": "Server ikke tilgængelig",
"error.serverSDK.noServerAvailable": "Ingen server tilgængelig",
"error.serverSDK.serverNotAvailable": "Server ikke tilgængelig",
"error.childStore.persistedCacheCreateFailed": "Kunne ikke oprette vedvarende cache",
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke oprette vedvarende projektmetadata",
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon",
+3 -3
View File
@@ -421,7 +421,7 @@ export const dict = {
"error.page.version": "Version: {{version}}",
"error.dev.rootNotFound":
"Wurzelelement nicht gefunden. Haben Sie vergessen, es in Ihre index.html aufzunehmen? Oder wurde das id-Attribut falsch geschrieben?",
"error.globalSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?",
"error.serverSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?",
"directory.error.invalidUrl": "Ungültiges Verzeichnis in der URL.",
"error.chain.unknown": "Unbekannter Fehler",
"error.chain.causedBy": "Verursacht durch:",
@@ -862,8 +862,8 @@ export const dict = {
"common.key.insert": "Einfg",
"common.unknown": "unbekannt",
"error.page.circular": "[Zirkulär]",
"error.globalSDK.noServerAvailable": "Kein Server verfügbar",
"error.globalSDK.serverNotAvailable": "Server nicht verfügbar",
"error.serverSDK.noServerAvailable": "Kein Server verfügbar",
"error.serverSDK.serverNotAvailable": "Server nicht verfügbar",
"error.childStore.persistedCacheCreateFailed": "Dauerhafter Cache konnte nicht erstellt werden",
"error.childStore.persistedProjectMetadataCreateFailed": "Dauerhafte Projektmetadaten konnten nicht erstellt werden",
"error.childStore.persistedProjectIconCreateFailed": "Dauerhaftes Projekticon konnte nicht erstellt werden",
+3 -3
View File
@@ -482,9 +482,9 @@ export const dict = {
"error.dev.rootNotFound":
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
"error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
"error.globalSDK.noServerAvailable": "No server available",
"error.globalSDK.serverNotAvailable": "Server not available",
"error.serverSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
"error.serverSDK.noServerAvailable": "No server available",
"error.serverSDK.serverNotAvailable": "Server not available",
"error.childStore.persistedCacheCreateFailed": "Failed to create persisted cache",
"error.childStore.persistedProjectMetadataCreateFailed": "Failed to create persisted project metadata",
"error.childStore.persistedProjectIconCreateFailed": "Failed to create persisted project icon",
+3 -3
View File
@@ -462,7 +462,7 @@ export const dict = {
"error.dev.rootNotFound":
"Elemento raíz no encontrado. ¿Olvidaste añadirlo a tu index.html? ¿O tal vez el atributo id está mal escrito?",
"error.globalSync.connectFailed": "No se pudo conectar al servidor. ¿Hay un servidor ejecutándose en `{{url}}`?",
"error.serverSync.connectFailed": "No se pudo conectar al servidor. ¿Hay un servidor ejecutándose en `{{url}}`?",
"directory.error.invalidUrl": "URL de directorio inválida.",
"error.chain.unknown": "Error desconocido",
@@ -933,8 +933,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "desconocido",
"error.page.circular": "[Circular]",
"error.globalSDK.noServerAvailable": "Ningún servidor disponible",
"error.globalSDK.serverNotAvailable": "Servidor no disponible",
"error.serverSDK.noServerAvailable": "Ningún servidor disponible",
"error.serverSDK.serverNotAvailable": "Servidor no disponible",
"error.childStore.persistedCacheCreateFailed": "Error al crear caché persistente",
"error.childStore.persistedProjectMetadataCreateFailed": "Error al crear metadatos de proyecto persistentes",
"error.childStore.persistedProjectIconCreateFailed": "Error al crear icono de proyecto persistente",
+3 -3
View File
@@ -417,7 +417,7 @@ export const dict = {
"error.page.version": "Version : {{version}}",
"error.dev.rootNotFound":
"Élément racine introuvable. Avez-vous oublié de l'ajouter à votre index.html ? Ou peut-être que l'attribut id est mal orthographié ?",
"error.globalSync.connectFailed":
"error.serverSync.connectFailed":
"Impossible de se connecter au serveur. Y a-t-il un serveur en cours d'exécution à `{{url}}` ?",
"directory.error.invalidUrl": "Répertoire invalide dans l'URL.",
"error.chain.unknown": "Erreur inconnue",
@@ -860,8 +860,8 @@ export const dict = {
"common.key.insert": "Inser",
"common.unknown": "inconnu",
"error.page.circular": "[Circulaire]",
"error.globalSDK.noServerAvailable": "Aucun serveur disponible",
"error.globalSDK.serverNotAvailable": "Serveur non disponible",
"error.serverSDK.noServerAvailable": "Aucun serveur disponible",
"error.serverSDK.serverNotAvailable": "Serveur non disponible",
"error.childStore.persistedCacheCreateFailed": "Échec de la création du cache persistant",
"error.childStore.persistedProjectMetadataCreateFailed":
"Échec de la création des métadonnées de projet persistantes",
+3 -3
View File
@@ -413,7 +413,7 @@ export const dict = {
"error.page.version": "バージョン: {{version}}",
"error.dev.rootNotFound":
"ルート要素が見つかりません。index.htmlに追加するのを忘れていませんか?またはid属性のスペルが間違っていませんか?",
"error.globalSync.connectFailed": "サーバーに接続できませんでした。`{{url}}`でサーバーが実行されていますか?",
"error.serverSync.connectFailed": "サーバーに接続できませんでした。`{{url}}`でサーバーが実行されていますか?",
"directory.error.invalidUrl": "URL内のディレクトリが無効です。",
"error.chain.unknown": "不明なエラー",
"error.chain.causedBy": "原因:",
@@ -844,8 +844,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "不明",
"error.page.circular": "[循環]",
"error.globalSDK.noServerAvailable": "利用可能なサーバーがありません",
"error.globalSDK.serverNotAvailable": "サーバーが利用できません",
"error.serverSDK.noServerAvailable": "利用可能なサーバーがありません",
"error.serverSDK.serverNotAvailable": "サーバーが利用できません",
"error.childStore.persistedCacheCreateFailed": "永続キャッシュの作成に失敗しました",
"error.childStore.persistedProjectMetadataCreateFailed": "永続プロジェクトメタデータの作成に失敗しました",
"error.childStore.persistedProjectIconCreateFailed": "永続プロジェクトアイコンの作成に失敗しました",
+3 -3
View File
@@ -412,7 +412,7 @@ export const dict = {
"error.page.version": "버전: {{version}}",
"error.dev.rootNotFound":
"루트 요소를 찾을 수 없습니다. index.html에 추가하는 것을 잊으셨나요? 또는 id 속성의 철자가 틀렸을 수 있습니다.",
"error.globalSync.connectFailed": "서버에 연결할 수 없습니다. `{{url}}`에서 서버가 실행 중인가요?",
"error.serverSync.connectFailed": "서버에 연결할 수 없습니다. `{{url}}`에서 서버가 실행 중인가요?",
"directory.error.invalidUrl": "URL에 유효하지 않은 디렉터리가 있습니다.",
"error.chain.unknown": "알 수 없는 오류",
"error.chain.causedBy": "원인:",
@@ -839,8 +839,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "알 수 없음",
"error.page.circular": "[순환]",
"error.globalSDK.noServerAvailable": "사용 가능한 서버 없음",
"error.globalSDK.serverNotAvailable": "서버를 사용할 수 없음",
"error.serverSDK.noServerAvailable": "사용 가능한 서버 없음",
"error.serverSDK.serverNotAvailable": "서버를 사용할 수 없음",
"error.childStore.persistedCacheCreateFailed": "영구 캐시 생성 실패",
"error.childStore.persistedProjectMetadataCreateFailed": "영구 프로젝트 메타데이터 생성 실패",
"error.childStore.persistedProjectIconCreateFailed": "영구 프로젝트 아이콘 생성 실패",
+3 -3
View File
@@ -463,7 +463,7 @@ export const dict = {
"error.dev.rootNotFound":
"Rotelement ikke funnet. Glemte du å legge det til i index.html? Eller kanskje id-attributten er feilstavet?",
"error.globalSync.connectFailed": "Kunne ikke koble til server. Kjører det en server på `{{url}}`?",
"error.serverSync.connectFailed": "Kunne ikke koble til server. Kjører det en server på `{{url}}`?",
"directory.error.invalidUrl": "Invalid directory in URL.",
"error.chain.unknown": "Ukjent feil",
@@ -927,8 +927,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "ukjent",
"error.page.circular": "[Sirkulær]",
"error.globalSDK.noServerAvailable": "Ingen server tilgjengelig",
"error.globalSDK.serverNotAvailable": "Server ikke tilgjengelig",
"error.serverSDK.noServerAvailable": "Ingen server tilgjengelig",
"error.serverSDK.serverNotAvailable": "Server ikke tilgjengelig",
"error.childStore.persistedCacheCreateFailed": "Kunne ikke opprette vedvarende hurtigbuffer",
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke opprette vedvarende prosjektmetadata",
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke opprette vedvarende prosjektikon",
+3 -3
View File
@@ -414,7 +414,7 @@ export const dict = {
"error.page.version": "Wersja: {{version}}",
"error.dev.rootNotFound":
"Nie znaleziono elementu głównego. Czy zapomniałeś dodać go do swojego index.html? A może atrybut id został błędnie wpisany?",
"error.globalSync.connectFailed": "Nie można połączyć się z serwerem. Czy serwer działa pod adresem `{{url}}`?",
"error.serverSync.connectFailed": "Nie można połączyć się z serwerem. Czy serwer działa pod adresem `{{url}}`?",
"directory.error.invalidUrl": "Nieprawidłowy katalog w URL.",
"error.chain.unknown": "Nieznany błąd",
"error.chain.causedBy": "Spowodowany przez:",
@@ -848,8 +848,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "nieznany",
"error.page.circular": "[Cykliczne]",
"error.globalSDK.noServerAvailable": "Brak dostępnego serwera",
"error.globalSDK.serverNotAvailable": "Serwer niedostępny",
"error.serverSDK.noServerAvailable": "Brak dostępnego serwera",
"error.serverSDK.serverNotAvailable": "Serwer niedostępny",
"error.childStore.persistedCacheCreateFailed": "Nie udało się utworzyć trwałej pamięci podręcznej",
"error.childStore.persistedProjectMetadataCreateFailed": "Nie udało się utworzyć trwałych metadanych projektu",
"error.childStore.persistedProjectIconCreateFailed": "Nie udało się utworzyć trwałej ikony projektu",
+3 -3
View File
@@ -461,7 +461,7 @@ export const dict = {
"error.dev.rootNotFound":
"Корневой элемент не найден. Вы забыли добавить его в index.html? Или, может быть, атрибут id был написан неправильно?",
"error.globalSync.connectFailed": "Не удалось подключиться к серверу. Запущен ли сервер по адресу `{{url}}`?",
"error.serverSync.connectFailed": "Не удалось подключиться к серверу. Запущен ли сервер по адресу `{{url}}`?",
"directory.error.invalidUrl": "Недопустимая директория в URL.",
"error.chain.unknown": "Неизвестная ошибка",
@@ -929,8 +929,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "неизвестно",
"error.page.circular": "[Циклично]",
"error.globalSDK.noServerAvailable": "Нет доступного сервера",
"error.globalSDK.serverNotAvailable": "Сервер недоступен",
"error.serverSDK.noServerAvailable": "Нет доступного сервера",
"error.serverSDK.serverNotAvailable": "Сервер недоступен",
"error.childStore.persistedCacheCreateFailed": "Не удалось создать постоянный кэш",
"error.childStore.persistedProjectMetadataCreateFailed": "Не удалось создать постоянные метаданные проекта",
"error.childStore.persistedProjectIconCreateFailed": "Не удалось создать постоянный значок проекта",
+3 -3
View File
@@ -459,7 +459,7 @@ export const dict = {
"error.dev.rootNotFound": "ไม่พบองค์ประกอบรูท คุณลืมเพิ่มใน index.html หรือบางทีแอตทริบิวต์ id อาจสะกดผิด?",
"error.globalSync.connectFailed": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ มีเซิร์ฟเวอร์ทำงานอยู่ที่ `{{url}}` หรือไม่?",
"error.serverSync.connectFailed": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ มีเซิร์ฟเวอร์ทำงานอยู่ที่ `{{url}}` หรือไม่?",
"directory.error.invalidUrl": "ไดเรกทอรีใน URL ไม่ถูกต้อง",
"error.chain.unknown": "ข้อผิดพลาดที่ไม่รู้จัก",
@@ -916,8 +916,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "ไม่ทราบ",
"error.page.circular": "[วงกลม]",
"error.globalSDK.noServerAvailable": "ไม่มีเซิร์ฟเวอร์",
"error.globalSDK.serverNotAvailable": "เซิร์ฟเวอร์ไม่พร้อมใช้งาน",
"error.serverSDK.noServerAvailable": "ไม่มีเซิร์ฟเวอร์",
"error.serverSDK.serverNotAvailable": "เซิร์ฟเวอร์ไม่พร้อมใช้งาน",
"error.childStore.persistedCacheCreateFailed": "ไม่สามารถสร้างแคชถาวร",
"error.childStore.persistedProjectMetadataCreateFailed": "ไม่สามารถสร้างเมตาดาต้าโปรเจกต์ถาวร",
"error.childStore.persistedProjectIconCreateFailed": "ไม่สามารถสร้างไอคอนโปรเจกต์ถาวร",
+3 -3
View File
@@ -465,7 +465,7 @@ export const dict = {
"error.dev.rootNotFound":
"Kök eleman bulunamadı. index.html dosyanıza eklemeyi unuttunuz mu? Ya da id özelliği yanlış mı yazıldı?",
"error.globalSync.connectFailed": "Sunucuya bağlanılamadı. `{{url}}` adresinde çalışan bir sunucu var mı?",
"error.serverSync.connectFailed": "Sunucuya bağlanılamadı. `{{url}}` adresinde çalışan bir sunucu var mı?",
"directory.error.invalidUrl": "URL'de geçersiz dizin.",
"error.chain.unknown": "Bilinmeyen hata",
@@ -935,8 +935,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "bilinmiyor",
"error.page.circular": "[Döngüsel]",
"error.globalSDK.noServerAvailable": "Sunucu yok",
"error.globalSDK.serverNotAvailable": "Sunucu mevcut değil",
"error.serverSDK.noServerAvailable": "Sunucu yok",
"error.serverSDK.serverNotAvailable": "Sunucu mevcut değil",
"error.childStore.persistedCacheCreateFailed": "Kalıcı önbellek oluşturulamadı",
"error.childStore.persistedProjectMetadataCreateFailed": "Kalıcı proje meta verileri oluşturulamadı",
"error.childStore.persistedProjectIconCreateFailed": "Kalıcı proje simgesi oluşturulamadı",
+3 -3
View File
@@ -483,9 +483,9 @@ export const dict = {
"error.dev.rootNotFound":
"Кореневий елемент не знайдено. Ви забули додати його до index.html? Або, можливо, атрибут id було написано з помилкою?",
"error.globalSync.connectFailed": "Не вдалося підключитися до сервера. Чи працює сервер за адресою `{{url}}`?",
"error.globalSDK.noServerAvailable": "Сервер недоступний",
"error.globalSDK.serverNotAvailable": "Сервер недоступний",
"error.serverSync.connectFailed": "Не вдалося підключитися до сервера. Чи працює сервер за адресою `{{url}}`?",
"error.serverSDK.noServerAvailable": "Сервер недоступний",
"error.serverSDK.serverNotAvailable": "Сервер недоступний",
"error.childStore.persistedCacheCreateFailed": "Не вдалося створити постійний кеш",
"error.childStore.persistedProjectMetadataCreateFailed": "Не вдалося створити постійні метадані проєкту",
"error.childStore.persistedProjectIconCreateFailed": "Не вдалося створити постійну іконку проєкту",
+3 -3
View File
@@ -462,7 +462,7 @@ export const dict = {
"error.page.report.discord": "在 Discord 上",
"error.page.version": "版本:{{version}}",
"error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html?或者 id 属性拼写错了?",
"error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
"error.serverSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
"directory.error.invalidUrl": "URL 中的目录无效。",
@@ -919,8 +919,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "未知",
"error.page.circular": "[循环]",
"error.globalSDK.noServerAvailable": "无可用服务器",
"error.globalSDK.serverNotAvailable": "服务器不可用",
"error.serverSDK.noServerAvailable": "无可用服务器",
"error.serverSDK.serverNotAvailable": "服务器不可用",
"error.childStore.persistedCacheCreateFailed": "创建持久化缓存失败",
"error.childStore.persistedProjectMetadataCreateFailed": "创建持久化项目元数据失败",
"error.childStore.persistedProjectIconCreateFailed": "创建持久化项目图标失败",
+3 -3
View File
@@ -457,7 +457,7 @@ export const dict = {
"error.dev.rootNotFound": "找不到根元素。你是不是忘了把它新增到 index.html? 或者 id 屬性拼錯了?",
"error.globalSync.connectFailed": "無法連線到伺服器。是否有伺服器正在 `{{url}}` 執行?",
"error.serverSync.connectFailed": "無法連線到伺服器。是否有伺服器正在 `{{url}}` 執行?",
"directory.error.invalidUrl": "URL 中的目錄無效。",
"error.chain.unknown": "未知錯誤",
@@ -907,8 +907,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "未知",
"error.page.circular": "[循環]",
"error.globalSDK.noServerAvailable": "無可用的伺服器",
"error.globalSDK.serverNotAvailable": "伺服器無法使用",
"error.serverSDK.noServerAvailable": "無可用的伺服器",
"error.serverSDK.serverNotAvailable": "伺服器無法使用",
"error.childStore.persistedCacheCreateFailed": "建立持續性快取失敗",
"error.childStore.persistedProjectMetadataCreateFailed": "建立持續性專案中繼資料失敗",
"error.childStore.persistedProjectIconCreateFailed": "建立持續性專案圖示失敗",
+5 -5
View File
@@ -19,7 +19,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useServer } from "@/context/server"
import { useGlobalSync } from "@/context/global-sync"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
@@ -55,7 +55,7 @@ export default function Home() {
}
function HomeDesign() {
const sync = useGlobalSync()
const sync = useServerSync()
const layout = useLayout()
const platform = usePlatform()
const dialog = useDialog()
@@ -358,10 +358,10 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
}
function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) {
const globalSync = useGlobalSync()
const serverSync = useServerSync()
const notification = useNotification()
const permission = usePermission()
const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false })
const [sessionStore] = serverSync.child(props.record.session.directory, { bootstrap: false })
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id))
@@ -459,7 +459,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
}
function LegacyHome() {
const sync = useGlobalSync()
const sync = useServerSync()
const layout = useLayout()
const platform = usePlatform()
const dialog = useDialog()
+43 -43
View File
@@ -16,7 +16,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useQuery } from "@tanstack/solid-query"
import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { useServerSync } from "@/context/server-sync"
import { Persist, persisted } from "@/utils/persist"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { decode64 } from "@/utils/base64"
@@ -35,7 +35,7 @@ import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, close
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
import { useServerSDK } from "@/context/server-sdk"
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import {
@@ -113,8 +113,8 @@ export default function Layout(props: ParentProps) {
let dialogDead = false
const params = useParams()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const layout = useLayout()
const layoutReady = createMemo(() => layout.ready())
const platform = usePlatform()
@@ -136,7 +136,7 @@ export default function Layout(props: ParentProps) {
if (!slug) return { slug, dir: "" }
const dir = decode64(slug)
if (!dir) return { slug, dir: "" }
const store = globalSync.peek(dir, { bootstrap: false })
const store = serverSync.peek(dir, { bootstrap: false })
return {
slug,
store,
@@ -226,7 +226,7 @@ export default function Layout(props: ParentProps) {
active: () => state.hoverProject,
el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
onActivate: (directory) => {
globalSync.child(directory)
serverSync.child(directory)
setState("hoverProject", directory)
},
})
@@ -410,7 +410,7 @@ export default function Layout(props: ParentProps) {
alertedAtBySession.delete(sessionKey)
}
const unsub = globalSDK.event.listen((e) => {
const unsub = serverSDK.event.listen((e) => {
if (e.details?.type === "worktree.ready") {
setBusy(e.name, false)
WorktreeState.ready(e.name)
@@ -444,7 +444,7 @@ export default function Layout(props: ParentProps) {
const props = e.details.properties
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
const [store] = globalSync.child(directory, { bootstrap: false })
const [store] = serverSync.child(directory, { bootstrap: false })
const session = store.session.find((s) => s.id === props.sessionID)
const sessionKey = `${directory}:${props.sessionID}`
@@ -507,7 +507,7 @@ export default function Layout(props: ParentProps) {
if (!currentDir() || !currentSession) return
const sessionKey = `${currentDir()}:${currentSession}`
dismissSessionAlert(sessionKey)
const [store] = globalSync.child(currentDir(), { bootstrap: false })
const [store] = serverSync.child(currentDir(), { bootstrap: false })
const childSessions = store.session.filter((s) => s.parentID === currentSession)
for (const child of childSessions) {
dismissSessionAlert(`${currentDir()}:${child.id}`)
@@ -545,11 +545,11 @@ export default function Layout(props: ParentProps) {
const direct = projects.find((p) => pathKey(p.worktree) === key)
if (direct) return direct
const [child] = globalSync.child(directory, { bootstrap: false })
const [child] = serverSync.child(directory, { bootstrap: false })
const id = child.project
if (!id) return
const meta = globalSync.data.project.find((p) => p.id === id)
const meta = serverSync.data.project.find((p) => p.id === id)
const root = meta?.worktree
if (!root) return
@@ -640,7 +640,7 @@ export default function Layout(props: ParentProps) {
const result: Session[] = []
for (const dir of dirs) {
const [dirStore] = globalSync.child(dir, { bootstrap: true })
const [dirStore] = serverSync.child(dir, { bootstrap: true })
const dirSessions = sortedRootSessions(dirStore, now)
result.push(...dirSessions)
}
@@ -692,7 +692,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
route()
globalSDK.url
serverSDK.url
prefetchToken.value += 1
clearSessionPrefetchInflight()
@@ -739,13 +739,13 @@ export default function Layout(props: ParentProps) {
}
async function prefetchMessages(directory: string, sessionID: string, token: number) {
const [store, setStore] = globalSync.child(directory, { bootstrap: false })
const [store, setStore] = serverSync.child(directory, { bootstrap: false })
return runSessionPrefetch({
directory,
sessionID,
task: (rev) =>
retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
retry(() => serverSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
.then((messages) => {
if (prefetchToken.value !== token) return
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
@@ -765,7 +765,7 @@ export default function Layout(props: ParentProps) {
if (stale.length > 0) {
clearSessionPrefetch(directory, stale)
for (const id of stale) {
globalSync.todo.set(id, undefined)
serverSync.todo.set(id, undefined)
}
}
@@ -830,7 +830,7 @@ export default function Layout(props: ParentProps) {
const directory = session.directory
if (!directory) return
const [store] = globalSync.child(directory, { bootstrap: false })
const [store] = serverSync.child(directory, { bootstrap: false })
const cached = untrack(() => {
const info = getSessionPrefetch(directory, session.id)
return shouldSkipSessionPrefetch({
@@ -935,7 +935,7 @@ export default function Layout(props: ParentProps) {
if (!target) return
// warm up child store to prevent flicker
globalSync.child(target.worktree)
serverSync.child(target.worktree)
void openProject(target.worktree)
}
@@ -944,7 +944,7 @@ export default function Layout(props: ParentProps) {
const target = projects[index]
if (!target) return
globalSync.child(target.worktree)
serverSync.child(target.worktree)
void openProject(target.worktree)
}
@@ -973,12 +973,12 @@ export default function Layout(props: ParentProps) {
}
async function archiveSession(session: Session) {
const [store, setStore] = globalSync.child(session.directory)
const [store, setStore] = serverSync.child(session.directory)
const sessions = store.session ?? []
const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1]
await globalSDK.client.session.update({
await serverSDK.client.session.update({
directory: session.directory,
sessionID: session.id,
time: { archived: Date.now() },
@@ -1246,11 +1246,11 @@ export default function Layout(props: ParentProps) {
)
if (known) return known[0]
const [child] = globalSync.child(directory, { bootstrap: false })
const [child] = serverSync.child(directory, { bootstrap: false })
const id = child.project
if (!id) return directory
const meta = globalSync.data.project.find((item) => item.id === id)
const meta = serverSync.data.project.find((item) => item.id === id)
return meta?.worktree ?? directory
}
@@ -1298,7 +1298,7 @@ export default function Layout(props: ParentProps) {
}
const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target)
const listed = await globalSDK.client.worktree
const listed = await serverSDK.client.worktree
.list({ directory: root })
.then((x) => x.data ?? [])
.catch(() => [] as string[])
@@ -1307,13 +1307,13 @@ export default function Layout(props: ParentProps) {
}
const openSession = async (target: { directory: string; id: string }) => {
if (!canOpen(target.directory)) return false
const [data] = globalSync.child(target.directory, { bootstrap: false })
const [data] = serverSync.child(target.directory, { bootstrap: false })
if (data.session.some((item) => item.id === target.id)) {
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
return true
}
const resolved = await globalSDK.client.session
const resolved = await serverSDK.client.session
.get({ sessionID: target.id })
.then((x) => x.data)
.catch(() => undefined)
@@ -1333,7 +1333,7 @@ export default function Layout(props: ParentProps) {
}
const latest = latestRootSession(
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
dirs.map((item) => serverSync.child(item, { bootstrap: false })[0]),
Date.now(),
)
if (latest && (await openSession(latest))) {
@@ -1344,7 +1344,7 @@ export default function Layout(props: ParentProps) {
await Promise.all(
dirs.map(async (item) => ({
path: { directory: item },
session: await globalSDK.client.session
session: await serverSDK.client.session
.list({ directory: item })
.then((x) => x.data ?? [])
.catch(() => []),
@@ -1405,11 +1405,11 @@ export default function Layout(props: ParentProps) {
const name = next === getFilename(project.worktree) ? "" : next
if (project.id && project.id !== "global") {
await globalSDK.client.project.update({ projectID: project.id, directory: project.worktree, name })
await serverSDK.client.project.update({ projectID: project.id, directory: project.worktree, name })
return
}
globalSync.project.meta(project.worktree, { name })
serverSync.project.meta(project.worktree, { name })
}
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
@@ -1506,7 +1506,7 @@ export default function Layout(props: ParentProps) {
setBusy(directory, true)
const result = await globalSDK.client.worktree
const result = await serverSDK.client.worktree
.remove({ directory: root, worktreeRemoveInput: { directory } })
.then((x) => x.data)
.catch((err) => {
@@ -1525,7 +1525,7 @@ export default function Layout(props: ParentProps) {
clearLastProjectSession(root)
}
globalSync.set(
serverSync.set(
"project",
produce((draft) => {
const project = draft.find((item) => item.worktree === root)
@@ -1564,7 +1564,7 @@ export default function Layout(props: ParentProps) {
})
const dismiss = () => toaster.dismiss(progress)
const sessions: Session[] = await globalSDK.client.session
const sessions: Session[] = await serverSDK.client.session
.list({ directory })
.then((x) => x.data ?? [])
.catch(() => [])
@@ -1575,9 +1575,9 @@ export default function Layout(props: ParentProps) {
platform,
getTerminalServerScope(server.current, server.key),
)
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
await serverSDK.client.instance.dispose({ directory }).catch(() => undefined)
const result = await globalSDK.client.worktree
const result = await serverSDK.client.worktree
.reset({ directory: root, worktreeResetInput: { directory } })
.then((x) => x.data)
.catch((err) => {
@@ -1599,7 +1599,7 @@ export default function Layout(props: ParentProps) {
sessions
.filter((session) => session.time.archived === undefined)
.map((session) =>
globalSDK.client.session
serverSDK.client.session
.update({
sessionID: session.id,
directory: session.directory,
@@ -1640,7 +1640,7 @@ export default function Layout(props: ParentProps) {
})
onMount(() => {
globalSDK.client.file
serverSDK.client.file
.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
@@ -1699,7 +1699,7 @@ export default function Layout(props: ParentProps) {
})
const refresh = async () => {
const sessions = await globalSDK.client.session
const sessions = await serverSDK.client.session
.list({ directory: props.directory })
.then((x) => x.data ?? [])
.catch(() => [])
@@ -1708,7 +1708,7 @@ export default function Layout(props: ParentProps) {
}
onMount(() => {
globalSDK.client.file
serverSDK.client.file
.status({ directory: props.directory })
.then((x) => {
const files = x.data ?? []
@@ -1842,7 +1842,7 @@ export default function Layout(props: ParentProps) {
const next = new Set(dirs)
for (const directory of next) {
if (loadedSessionDirs.has(directory)) continue
void globalSync.project.loadSessions(directory)
void serverSync.project.loadSessions(directory)
}
loadedSessionDirs.clear()
@@ -1939,7 +1939,7 @@ export default function Layout(props: ParentProps) {
const createWorkspace = async (project: LocalProject) => {
clearSidebarHoverState()
const created = await globalSDK.client.worktree
const created = await serverSDK.client.worktree
.create({ directory: project.worktree })
.then((x) => x.data)
.catch((err) => {
@@ -1973,7 +1973,7 @@ export default function Layout(props: ParentProps) {
return [created.directory, ...next]
})
globalSync.child(created.directory)
serverSync.child(created.directory)
navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
}
@@ -2078,7 +2078,7 @@ export default function Layout(props: ParentProps) {
if (!item) return false
return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
})
const homedir = createMemo(() => globalSync.data.path.home)
const homedir = createMemo(() => serverSync.data.path.home)
return (
<div
@@ -7,7 +7,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/core/util/path"
import { A, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
import { useGlobalSync } from "@/context/global-sync"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
@@ -23,7 +23,7 @@ export const ProjectIcon = (props: {
notify?: boolean
working?: boolean
}): JSX.Element => {
const globalSync = useGlobalSync()
const serverSync = useServerSync()
const notification = useNotification()
const permission = usePermission()
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
@@ -33,7 +33,7 @@ export const ProjectIcon = (props: {
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
const hasPermissions = createMemo(() =>
dirs().some((directory) => {
const [store] = globalSync.child(directory, { bootstrap: false })
const [store] = serverSync.child(directory, { bootstrap: false })
return hasProjectPermissions(store.permission, (item) => !permission.autoResponds(item, directory))
}),
)
@@ -146,10 +146,10 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const language = useLanguage()
const notification = useNotification()
const permission = usePermission()
const globalSync = useGlobalSync()
const serverSync = useServerSync()
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
const [sessionStore] = globalSync.child(props.session.directory)
const [sessionStore] = serverSync.child(props.session.directory)
const hasPermissions = createMemo(() => {
return !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.session.id, (item) => {
return !permission.autoResponds(item, props.session.directory)
@@ -7,7 +7,7 @@ import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon"
import { createSortable } from "@thisbeyond/solid-dnd"
import { useLayout, type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
@@ -274,7 +274,7 @@ export const SortableProject = (props: {
ctx: ProjectSidebarContext
sortNow: Accessor<number>
}): JSX.Element => {
const globalSync = useGlobalSync()
const serverSync = useServerSync()
const language = useLanguage()
const sortable = createSortable(props.project.worktree)
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
@@ -294,23 +294,23 @@ export const SortableProject = (props: {
const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
const label = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false })
const [data] = serverSync.child(directory, { bootstrap: false })
const kind =
directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id)
return `${kind} : ${name}`
}
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
const projectStore = createMemo(() => serverSync.child(props.project.worktree, { bootstrap: false })[0])
const isWorking = createMemo(() =>
dirs().some((directory) => {
const [store] = globalSync.child(directory, { bootstrap: false })
const [store] = serverSync.child(directory, { bootstrap: false })
return Object.keys(store.session_status).some((id) => store.session_working(id))
}),
)
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
const workspaceSessions = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false })
const [data] = serverSync.child(directory, { bootstrap: false })
return sortedRootSessions(data, props.sortNow())
}
const tile = () => (
@@ -14,7 +14,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { type LocalProject } from "@/context/layout"
import { useGlobalSync, useQueryOptions } from "@/context/global-sync"
import { useServerSync, useQueryOptions } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { pathKey } from "@/utils/path-key"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
@@ -60,7 +60,7 @@ export const WorkspaceDragOverlay = (props: {
activeWorkspace: Accessor<string | undefined>
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
}): JSX.Element => {
const globalSync = useGlobalSync()
const serverSync = useServerSync()
const language = useLanguage()
const label = createMemo(() => {
const project = props.sidebarProject()
@@ -68,7 +68,7 @@ export const WorkspaceDragOverlay = (props: {
const directory = props.activeWorkspace()
if (!directory) return
const [workspaceStore] = globalSync.child(directory, { bootstrap: false })
const [workspaceStore] = serverSync.child(directory, { bootstrap: false })
const kind =
directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
@@ -299,11 +299,11 @@ export const SortableWorkspace = (props: {
}): JSX.Element => {
const navigate = useNavigate()
const params = useParams()
const globalSync = useGlobalSync()
const serverSync = useServerSync()
const queryOptions = useQueryOptions()
const language = useLanguage()
const sortable = createSortable(props.directory)
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
const [workspaceStore, setWorkspaceStore] = serverSync.child(props.directory, { bootstrap: false })
const [menu, setMenu] = createStore({
open: false,
pendingRename: false,
@@ -328,7 +328,7 @@ export const SortableWorkspace = (props: {
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
const loadMore = async () => {
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.directory)
await serverSync.project.loadSessions(props.directory)
}
const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
@@ -357,7 +357,7 @@ export const SortableWorkspace = (props: {
createEffect(() => {
if (!boot()) return
globalSync.child(props.directory, { bootstrap: true })
serverSync.child(props.directory, { bootstrap: true })
})
return (
@@ -446,11 +446,11 @@ export const LocalWorkspace = (props: {
sortNow: Accessor<number>
mobile?: boolean
}): JSX.Element => {
const globalSync = useGlobalSync()
const serverSync = useServerSync()
const queryOptions = useQueryOptions()
const language = useLanguage()
const workspace = createMemo(() => {
const [store, setStore] = globalSync.child(props.project.worktree)
const [store, setStore] = serverSync.child(props.project.worktree)
return { store, setStore }
})
const slug = createMemo(() => base64Encode(props.project.worktree))
@@ -461,7 +461,7 @@ export const LocalWorkspace = (props: {
const loading = () => fetching() > 0 && count() === 0
const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.project.worktree)
await serverSync.project.loadSessions(props.project.worktree)
}
return (
+8 -8
View File
@@ -34,7 +34,7 @@ import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionDesignView, NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useGlobalSync } from "@/context/global-sync"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
@@ -182,7 +182,7 @@ function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
}
export default function Page() {
const globalSync = useGlobalSync()
const serverSync = useServerSync()
const layout = useLayout()
const local = useLocal()
const file = useFile()
@@ -560,11 +560,11 @@ export default function Page() {
}
function upsert(next: Project) {
const list = globalSync.data.project
const list = serverSync.data.project
sync.set("project", next.id)
const idx = list.findIndex((item) => item.id === next.id)
if (idx >= 0) {
globalSync.set(
serverSync.set(
"project",
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
)
@@ -572,10 +572,10 @@ export default function Page() {
}
const at = list.findIndex((item) => item.id > next.id)
if (at >= 0) {
globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
serverSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
return
}
globalSync.set("project", [...list, next])
serverSync.set("project", [...list, next])
}
const gitMutation = useMutation(() => ({
@@ -673,7 +673,7 @@ export default function Page() {
todoTimer = undefined
if (!id) return
if (status === "idle" && !blocked) return
const cached = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
const cached = untrack(() => sync.data.todo[id] !== undefined || serverSync.data.session_todo[id] !== undefined)
todoFrame = requestAnimationFrame(() => {
todoFrame = undefined
@@ -1386,7 +1386,7 @@ export default function Page() {
const ok = await sendFollowupDraft({
client: sdk.client,
sync,
globalSync,
serverSync,
draft: item,
optimisticBusy: item.sessionDirectory === sdk.directory,
}).catch((err) => {
@@ -3,7 +3,7 @@ import { createStore } from "solid-js/store"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { showToast } from "@opencode-ai/ui/toast"
import { useGlobalSync } from "@/context/global-sync"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { useSDK } from "@/context/sdk"
@@ -27,7 +27,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
const serverSync = useServerSync()
const language = useLanguage()
const permission = usePermission()
@@ -50,7 +50,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
const todos = createMemo((): Todo[] => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
return serverSync.data.session_todo[id] ?? []
})
const done = createMemo(
@@ -111,7 +111,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
const clear = () => {
const id = params.id
if (!id) return
globalSync.todo.set(id, [])
serverSync.todo.set(id, [])
sync.set("todo", id, [])
}
@@ -23,7 +23,6 @@ export type TimelineRowMap = {
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
lastAssistantPart: boolean
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Retry: { userMessageID: string }
@@ -50,7 +49,6 @@ export namespace TimelineRow {
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
lastAssistantPart: boolean
}> {}
export class Thinking extends Data.TaggedClass("Thinking")<{
userMessageID: string
@@ -151,8 +149,6 @@ export namespace Timeline {
),
]
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
const assistantGroupCount = assistantItems.filter((item) => item.type === "part").length
if (comments.length > 0)
rows.push(
new TimelineRow.CommentStrip({
@@ -195,7 +191,6 @@ export namespace Timeline {
userMessageID: userMessage.id,
group: item.group,
previousAssistantPart: assistantGroupIndex > 0,
lastAssistantPart: assistantGroupIndex === assistantGroupCount - 1,
}),
)
assistantGroupIndex += 1
@@ -1,4 +1,16 @@
import { createEffect, createMemo, createSignal, For, Index, on, onCleanup, Show, mapArray, type JSX } from "solid-js"
import {
createEffect,
createMemo,
createSignal,
For,
Index,
on,
onCleanup,
Show,
mapArray,
type Accessor,
type JSX,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useNavigate } from "@solidjs/router"
@@ -48,7 +60,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useGlobalSDK } from "@/context/global-sdk"
import { useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
@@ -245,7 +257,7 @@ function TimelineDiffView(props: { diff: SummaryDiff }) {
return (
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
<Dynamic component={fileComponent} mode="diff" virtualize={false} fileDiff={view.fileDiff} />
</div>
)
}
@@ -273,7 +285,7 @@ export function MessageTimeline(props: {
let touchGesture: number | undefined
const navigate = useNavigate()
const globalSDK = useGlobalSDK()
const serverSDK = useServerSDK()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
@@ -415,8 +427,7 @@ export function MessageTimeline(props: {
if (rows.length === 0) return rows
return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()])
})
const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [TimelineRow.key(row), row] as const)))
const timelineRowKeys = createMemo(() => [...timelineRowByKey().keys()], [] as string[], { equals: sameKeys })
const timelineRowKeys = createMemo(() => timelineRows().map(TimelineRow.key), [] as string[], { equals: sameKeys })
const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
const messageRowIndex = createMemo(() => {
const result = new Map<string, number>()
@@ -427,6 +438,14 @@ export function MessageTimeline(props: {
})
return result
})
const lastAssistantGroupKey = createMemo(() => {
const result = new Map<string, string>()
timelineRows().forEach((row) => {
if (row._tag !== "AssistantPart") return
result.set(row.userMessageID, row.group.key)
})
return result
})
const keepMounted = createMemo(() => {
const id = activeMessageID()
if (!id) return
@@ -540,6 +559,7 @@ export function MessageTimeline(props: {
const [bar, setBar] = createStore({
ms: pace(640),
})
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
let more: HTMLButtonElement | undefined
let head: HTMLDivElement | undefined
@@ -560,6 +580,11 @@ export function MessageTimeline(props: {
const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4
const measureTimeline = () => {
virtualizer?.measure()
anchorMeasuredBottom()
}
function anchorMeasuredBottom() {
if (!listRoot) return false
if (!measuredBottomAnchored) return false
@@ -704,14 +729,14 @@ export function MessageTimeline(props: {
}
const shareMutation = useMutation(() => ({
mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
mutationFn: (id: string) => serverSDK.client.session.share({ sessionID: id, directory: sdk.directory }),
onError: (err) => {
console.error("Failed to share session", err)
},
}))
const unshareMutation = useMutation(() => ({
mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
mutationFn: (id: string) => serverSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }),
onError: (err) => {
console.error("Failed to unshare session", err)
},
@@ -980,29 +1005,42 @@ export function MessageTimeline(props: {
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const renderAssistantPartGroup = (row: TimelineRowMap["AssistantPart"]) => {
if (row.group.type === "context") {
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>) => {
if (row().group.type === "context") {
const parts = createMemo(() => {
const group = row.group
const group = row().group
if (group.type !== "context") return emptyTools
return group.refs
.map((ref) => getMsgPart(ref.messageID, ref.partID))
.filter((part): part is ToolPart => part?.type === "tool")
})
return <ContextToolGroup parts={parts()} busy={workingTurn(row.userMessageID) && row.lastAssistantPart} />
return (
<ContextToolGroup
parts={parts()}
busy={
workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key
}
onSizeChange={measureTimeline}
/>
)
}
const message = createMemo(() => {
const group = row.group
const group = row().group
if (group.type !== "part") return
return messageByID().get(group.ref.messageID)
})
const part = createMemo(() => {
const group = row.group
const group = row().group
if (group.type !== "part") return
return getMsgPart(group.ref.messageID, group.ref.partID)
})
const defaultOpen = createMemo(() => {
const item = part()
if (!item) return
return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded())
})
return (
<Show when={message()}>
@@ -1012,14 +1050,13 @@ export function MessageTimeline(props: {
<MessagePart
part={part()}
message={message()}
showAssistantCopyPartID={assistantCopyPartID(row.userMessageID)}
turnDurationMs={turnDurationMs(row.userMessageID)}
defaultOpen={partDefaultOpen(
part(),
settings.general.shellToolPartsExpanded(),
settings.general.editToolPartsExpanded(),
)}
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
turnDurationMs={turnDurationMs(row().userMessageID)}
defaultOpen={defaultOpen()}
toolOpen={toolOpen[part().id] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(part().id, open)}
deferToolContent={false}
virtualizeDiff={false}
/>
)}
</Show>
@@ -1028,25 +1065,25 @@ export function MessageTimeline(props: {
)
}
function TimelineRowFrame(input: { row: FramedTimelineRow; children: JSX.Element }) {
function TimelineRowFrame(input: { row: Accessor<FramedTimelineRow>; children: JSX.Element }) {
const anchor = () => {
const row = input.row
const row = input.row()
return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
}
const previousUserMessage = () => {
const row = input.row
const row = input.row()
return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
}
const previousAssistantPart = () => {
const row = input.row
const row = input.row()
return row._tag === "AssistantPart" && row.previousAssistantPart
}
return (
<div
id={anchor() ? props.anchor(input.row.userMessageID) : undefined}
data-message-id={input.row.userMessageID}
data-timeline-row={input.row._tag}
id={anchor() ? props.anchor(input.row().userMessageID) : undefined}
data-message-id={input.row().userMessageID}
data-timeline-row={input.row()._tag}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
@@ -1062,14 +1099,15 @@ export function MessageTimeline(props: {
)
}
const renderTimelineRow = (row: TimelineRow.TimelineRow) => {
switch (row._tag) {
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => {
switch (row()._tag) {
case "CommentStrip": {
const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
const comments = createMemo(() =>
getMsgParts(row.userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
)
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={commentStripRow}>
<div class="w-full px-4 md:px-5 pb-2">
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
@@ -1102,17 +1140,22 @@ export function MessageTimeline(props: {
)
}
case "UserMessage": {
const userMessageRow = row as Accessor<TimelineRowByTag<"UserMessage">>
const message = createMemo(() => {
const m = messageByID().get(row.userMessageID)
const m = messageByID().get(userMessageRow().userMessageID)
if (m?.role === "user") return m
})
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={userMessageRow}>
<Show when={message()}>
{(message) => (
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-content" aria-live="off">
<Message message={message()} parts={getMsgParts(row.userMessageID)} actions={props.actions} />
<Message
message={message()}
parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions}
/>
</div>
</div>
)}
@@ -1121,13 +1164,14 @@ export function MessageTimeline(props: {
)
}
case "TurnDivider": {
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={turnDividerRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-compaction">
<MessageDivider
label={language.t(
row.label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
turnDividerRow().label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
)}
/>
</div>
@@ -1136,22 +1180,27 @@ export function MessageTimeline(props: {
)
}
case "AssistantPart": {
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={assistantPartRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(row.userMessageID)}>
{renderAssistantPartGroup(row)}
<div
data-slot="session-turn-assistant-content"
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
>
{renderAssistantPartGroup(assistantPartRow)}
</div>
</div>
</TimelineRowFrame>
)
}
case "Thinking": {
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={thinkingRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineThinkingRow
reasoningHeading={row.reasoningHeading}
reasoningHeading={thinkingRow().reasoningHeading}
showReasoningSummaries={settings.general.showReasoningSummaries()}
/>
</div>
@@ -1159,29 +1208,32 @@ export function MessageTimeline(props: {
)
}
case "Retry": {
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={retryRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<SessionRetry status={sessionStatus()} show={activeMessageID() === row.userMessageID} />
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
</div>
</TimelineRowFrame>
)
}
case "DiffSummary": {
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={diffSummaryRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineDiffSummaryRow diffs={row.diffs} />
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
</div>
</TimelineRowFrame>
)
}
case "Error": {
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
return (
<TimelineRowFrame row={row}>
<TimelineRowFrame row={errorRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<Card variant="error" class="error-card">
{row.text}
{errorRow().text}
</Card>
</div>
</TimelineRowFrame>
@@ -1192,12 +1244,8 @@ export function MessageTimeline(props: {
}
}
function TimelineRowView(props: { rowKey: string }) {
return (
<Show when={timelineRowByKey().get(props.rowKey)} keyed>
{(item) => renderTimelineRow(item)}
</Show>
)
function TimelineRowView(props: { row: TimelineRow.TimelineRow }) {
return renderTimelineRow(() => props.row)
}
return (
@@ -1529,7 +1577,7 @@ export function MessageTimeline(props: {
<Show when={scrollRoot()}>
{(root) => (
<Virtualizer
data={timelineRowKeys()}
data={timelineRows()}
cache={virtualCache()}
itemSize={virtualCache() ? undefined : timelineFallbackItemSize}
scrollRef={root()}
@@ -1549,7 +1597,7 @@ export function MessageTimeline(props: {
scheduleContentRoot(root())
}}
>
{(key) => <TimelineRowView rowKey={key} />}
{(row) => <TimelineRowView row={row} />}
</Virtualizer>
)}
</Show>
+26
View File
@@ -0,0 +1,26 @@
import { onCleanup } from "solid-js"
export function createRefCountMap<T>(create: (key: string) => T) {
const items = new Map<string, T>()
const refCounts = new Map<string, number>()
return (key: string) => {
onCleanup(() => {
refCounts.set(key, (refCounts.get(key) ?? 0) - 1)
if (refCounts.get(key) === 0) {
items.delete(key)
refCounts.delete(key)
}
})
const cached = items.get(key)
if (cached) {
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
return cached
}
const item = create(key)
items.set(key, item)
refCounts.set(key, 1)
return item
}
}
+3 -3
View File
@@ -27,15 +27,15 @@
},
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/amazon-bedrock": "4.0.107",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/google": "3.0.75",
"@ai-sdk/google-vertex": "4.0.131",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
+216 -21
View File
@@ -57,6 +57,11 @@ const OpenAIResponsesReasoningItem = Schema.Struct({
encrypted_content: optionalNull(Schema.String),
})
const OpenAIResponsesItemReference = Schema.Struct({
type: Schema.tag("item_reference"),
id: Schema.String,
})
// `function_call_output.output` accepts either a plain string or an ordered
// array of content items so tools can return images in addition to text.
// https://platform.openai.com/docs/api-reference/responses/object
@@ -72,6 +77,7 @@ const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
OpenAIResponsesReasoningItem,
OpenAIResponsesItemReference,
Schema.Struct({
type: Schema.tag("function_call"),
call_id: Schema.String,
@@ -86,6 +92,15 @@ const OpenAIResponsesInputItem = Schema.Union([
])
type OpenAIResponsesInputItem = Schema.Schema.Type<typeof OpenAIResponsesInputItem>
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
// multiple streamed summary parts into the same item before flushing.
type OpenAIResponsesReasoningInput = {
type: "reasoning"
id: string
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
}
const OpenAIResponsesTool = Schema.Struct({
type: Schema.tag("function"),
name: Schema.String,
@@ -112,7 +127,7 @@ const OpenAIResponsesCoreFields = {
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
store: Schema.optional(Schema.Boolean),
prompt_cache_key: Schema.optional(Schema.String),
include: optionalArray(Schema.Literal("reasoning.encrypted_content")),
include: optionalArray(OpenAIOptions.OpenAIResponseIncludable),
reasoning: Schema.optional(
Schema.Struct({
effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
@@ -193,6 +208,7 @@ const OpenAIResponsesEvent = Schema.Struct({
type: Schema.String,
delta: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(OpenAIResponsesStreamItem),
response: Schema.optional(
Schema.StructWithRest(
@@ -216,6 +232,18 @@ interface ParserState {
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
interface ReasoningStreamItem {
readonly encryptedContent: string | null | undefined
// Keyed by OpenAI's numeric `summary_index`. JS object keys coerce to
// strings, but typing the map as `Record<number, ...>` documents intent
// and matches the wire field.
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
}
const invalid = ProviderShared.invalidRequest
@@ -245,22 +273,21 @@ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
arguments: ProviderShared.encodeJson(part.input),
})
const lowerReasoning = (part: ReasoningPart, store: boolean | undefined): OpenAIResponsesInputItem | undefined => {
const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | undefined => {
const openai = part.providerMetadata?.openai
if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string") return undefined
// With store:false, OpenAI only accepts previous reasoning items when the
// encrypted state is present. Bare rs_* ids point to non-persisted items.
if (store === false && typeof openai.reasoningEncryptedContent !== "string") return undefined
if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string" || openai.itemId.length === 0)
return undefined
const encryptedContent =
typeof openai.reasoningEncryptedContent === "string"
? openai.reasoningEncryptedContent
: openai.reasoningEncryptedContent === null
? null
: undefined
return {
type: "reasoning",
id: openai.itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content:
typeof openai.reasoningEncryptedContent === "string"
? openai.reasoningEncryptedContent
: openai.reasoningEncryptedContent === null
? null
: undefined,
encrypted_content: encryptedContent,
}
}
@@ -310,6 +337,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenAIResponsesReasoningInput> = {}
const reasoningReferences = new Set<string>()
const flushText = () => {
if (content.length === 0) return
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
@@ -322,8 +351,22 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, store)
if (reasoning) input.push(reasoning)
const reasoning = lowerReasoning(part)
if (!reasoning) continue
if (store !== false && reasoning.id) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
continue
}
const existing = reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
if (typeof reasoning.encrypted_content === "string")
existing.encrypted_content = reasoning.encrypted_content
continue
}
reasoningItems[reasoning.id] = reasoning
input.push(reasoning)
continue
}
if (part.type === "tool-call") {
@@ -352,7 +395,14 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
}
}
return input
// With store:false, OpenAI only accepts previous reasoning items when the
// complete item has encrypted state. Summary blocks for one item may carry
// that state only on the last block, so filter after they have been joined.
return store === false
? input.filter(
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
)
: input
})
const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) {
@@ -362,14 +412,14 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
if (effort && !OpenAIOptions.isReasoningEffort(effort))
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
const summary = OpenAIOptions.reasoningSummary(request)
const encryptedState = OpenAIOptions.encryptedReasoning(request)
const include = OpenAIOptions.include(request)
const verbosity = OpenAIOptions.textVerbosity(request)
const instructions = OpenAIOptions.instructions(request)
return {
...(instructions ? { instructions } : {}),
...(store !== undefined ? { store } : {}),
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}),
...(include ? { include } : {}),
...(effort || summary ? { reasoning: { effort, summary } } : {}),
...(verbosity ? { text: { verbosity } } : {}),
}
@@ -517,24 +567,51 @@ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): Ste
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const itemID = event.item_id ?? "reasoning-0"
const id =
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, event.item_id ?? "reasoning-0", event.delta),
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
},
events,
]
}
// The summary done event does not carry encrypted continuation state. Finish the
// common reasoning block when the full reasoning item arrives in output_item.done.
const onReasoningDone = (state: ParserState, _event: OpenAIResponsesEvent): StepResult => [state, NO_EVENTS]
const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
openaiMetadata({ itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// OpenAI Responses streams reasoning items in a stable order:
// `output_item.added` (reasoning) →
// `reasoning_summary_part.added` (index=0) →
// `reasoning_summary_text.delta` →
// `reasoning_summary_part.done` (index=0) →
// (repeat for index>0) →
// `output_item.done` (reasoning).
// The handlers below rely on this ordering: `onOutputItemAdded` seeds the
// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
// short-circuits when the entry already exists, and higher-index handlers
// fold against the same entry. Behaviour for out-of-order events is
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
const item = event.item
if (item && isReasoningItem(item)) {
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(item)),
reasoningItems: {
...state.reasoningItems,
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
},
},
events,
]
}
if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
const providerMetadata = openaiMetadata({ itemId: item.id })
const events: LLMEvent[] = []
@@ -555,6 +632,103 @@ const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): Ste
]
}
const onReasoningSummaryPartAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} }
if (event.summary_index === 0) {
if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
state.lifecycle,
events,
`${event.item_id}:0`,
openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: null }),
),
reasoningItems: {
...state.reasoningItems,
[event.item_id]: { ...item, summaryParts: { 0: "active" } },
},
},
events,
]
}
const events: LLMEvent[] = []
const closed = Object.entries(item.summaryParts)
.filter((entry) => entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) =>
Lifecycle.reasoningEnd(
lifecycle,
events,
`${event.item_id}:${entry[0]}`,
openaiMetadata({ itemId: event.item_id }),
),
state.lifecycle,
)
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
closed,
events,
`${event.item_id}:${event.summary_index}`,
openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
),
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...Object.fromEntries(
Object.entries(item.summaryParts).map((entry) =>
entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
),
),
[event.summary_index]: "active",
},
},
},
},
events,
]
}
const onReasoningSummaryPartDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle:
state.store !== false
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
`${event.item_id}:${event.summary_index}`,
openaiMetadata({ itemId: event.item_id }),
)
: state.lifecycle,
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...item.summaryParts,
[event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
},
},
},
},
events,
]
}
const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallArgumentsDelta")(function* (
state: ParserState,
event: OpenAIResponsesEvent,
@@ -615,6 +789,17 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
if (isReasoningItem(item)) {
const events: LLMEvent[] = []
const providerMetadata = reasoningMetadata(item)
const reasoningItem = state.reasoningItems[item.id]
if (reasoningItem) {
const lifecycle = Object.entries(reasoningItem.summaryParts)
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
.reduce(
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, providerMetadata),
state.lifecycle,
)
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
}
if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata }))
@@ -683,6 +868,10 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
event.type === "response.reasoning_summary_text.done"
)
return Effect.succeed(onReasoningDone(state, event))
if (event.type === "response.reasoning_summary_part.added")
return Effect.succeed(onReasoningSummaryPartAdded(state, event))
if (event.type === "response.reasoning_summary_part.done")
return Effect.succeed(onReasoningSummaryPartDone(state, event))
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
@@ -709,7 +898,13 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(OpenAIResponsesEvent),
initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty<string>(), lifecycle: Lifecycle.initial() }),
initial: (request) => ({
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
reasoningItems: {},
store: OpenAIOptions.store(request),
}),
step,
terminal: (event) => TERMINAL_TYPES.has(event.type),
},
+14 -6
View File
@@ -24,16 +24,24 @@ export const textDelta = (state: State, events: LLMEvent[], id: string, text: st
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
export const reasoningStart = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
): State => {
if (state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
if (stepped.reasoning.has(id)) {
events.push(LLMEvent.reasoningDelta({ id, text }))
return stepped
}
events.push(LLMEvent.reasoningStart({ id }), LLMEvent.reasoningDelta({ id, text }))
events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
}
export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const started = reasoningStart(state, events, id)
events.push(LLMEvent.reasoningDelta({ id, text }))
return started
}
export const reasoningEnd = (
state: State,
events: LLMEvent[],
@@ -7,12 +7,28 @@ export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
)
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
export const OpenAIResponseIncludables = [
"file_search_call.results",
"web_search_call.results",
"web_search_call.action.sources",
"message.input_image.image_url",
"computer_call_output.output.image_url",
"code_interpreter_call.outputs",
"reasoning.encrypted_content",
"message.output_text.logprobs",
] as const
export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number]
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
export const OpenAITextVerbosity = TextVerbosity
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
typeof effort === "string" && REASONING_EFFORTS.has(effort)
@@ -35,12 +51,20 @@ export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefine
return isAnyReasoningEffort(value) ? value : undefined
}
export const reasoningSummary = (request: LLMRequest): "auto" | undefined => {
return options(request)?.reasoningSummary === "auto" ? "auto" : undefined
}
export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
options(request)?.reasoningSummary === "auto" ? "auto" : undefined
export const encryptedReasoning = (request: LLMRequest) =>
options(request)?.includeEncryptedReasoning === true ? true : undefined
// Resolve the OpenAI Responses `include` field. Filters out unknown
// includable values defensively so a typo in upstream config drops the
// invalid entry instead of poisoning the wire body. An empty array (either
// passed directly or produced by filtering) is treated as "no include" and
// returns undefined so the request body omits the field entirely.
export const include = (request: LLMRequest): ReadonlyArray<OpenAIResponseIncludable> | undefined => {
const value = options(request)?.include
if (!Array.isArray(value)) return undefined
const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry))
return filtered.length > 0 ? filtered : undefined
}
export const promptCacheKey = (request: LLMRequest) => {
const value = options(request)?.promptCacheKey
+14 -2
View File
@@ -1,5 +1,8 @@
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
import { mergeProviderOptions } from "../schema"
import type { OpenAIResponseIncludable } from "../protocols/utils/openai-options"
export type { OpenAIResponseIncludable } from "../protocols/utils/openai-options"
export interface OpenAIOptionsInput {
readonly [key: string]: unknown
@@ -7,7 +10,10 @@ export interface OpenAIOptionsInput {
readonly promptCacheKey?: string
readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: "auto"
readonly includeEncryptedReasoning?: boolean
// OpenAI Responses `include` wire field. Mirrors the official SDK's
// `ResponseIncludable[]` union exactly so AI SDK callers and direct
// native-SDK callers share one shape and no translation is required.
readonly include?: ReadonlyArray<OpenAIResponseIncludable>
readonly textVerbosity?: TextVerbosity
}
@@ -25,7 +31,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
promptCacheKey: options?.promptCacheKey,
reasoningEffort: options?.reasoningEffort,
reasoningSummary: options?.reasoningSummary,
includeEncryptedReasoning: options?.includeEncryptedReasoning,
include: options?.include,
textVerbosity: options?.textVerbosity,
}),
)
@@ -42,6 +48,12 @@ export const gpt5DefaultOptions = (
return openAIProviderOptions({
reasoningEffort: "medium",
reasoningSummary: "auto",
// GPT-5 reasoning models are configured stateless (`store: false`) by
// `openAIDefaultOptions` below, so the only way a follow-up turn can
// carry reasoning state is via the encrypted reasoning include. Without
// this, callers using the default model facade get reasoning summaries
// they cannot replay statelessly.
include: ["reasoning.encrypted_content"],
textVerbosity:
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
? "low"
+1 -1
View File
@@ -5,7 +5,7 @@ import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export type { OpenAIOptionsInput } from "./openai-options"
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
export const id = ProviderID.make("openai")
+1 -1
View File
@@ -283,7 +283,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
)
return events.pipe(
Stream.mapAccumEffect(
protocol.stream.initial,
() => protocol.stream.initial(request),
protocol.stream.step,
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
),
+2 -2
View File
@@ -52,8 +52,8 @@ export interface ProtocolBody<Body> {
export interface ProtocolStream<Frame, Event, State> {
/** Schema for one decoded streaming event, decoded from a transport frame. */
readonly event: Schema.Codec<Event, Frame>
/** Initial parser state. Called once per response. */
readonly initial: () => State
/** Initial parser state. Called once per response with the resolved request. */
readonly initial: (request: LLMRequest) => State
/** Translate one event into emitted `LLMEvent`s plus the next state. */
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], LLMError>
/** Optional request-completion signal for transports that do not end naturally. */
+1 -1
View File
@@ -97,7 +97,7 @@ export function continuationRequest(input: {
tools: features.has("tool-call") ? [continuationTool] : [],
cache: "none",
providerOptions: features.has("encrypted-reasoning")
? { openai: { store: false, includeEncryptedReasoning: true, reasoningSummary: "auto" } }
? { openai: { store: false, include: ["reasoning.encrypted_content"], reasoningSummary: "auto" } }
: undefined,
generation: { maxTokens: 80, temperature: 0 },
})
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -393,7 +393,7 @@ describe("OpenAI Responses route", () => {
promptCacheKey: "session_123",
reasoningEffort: "high",
reasoningSummary: "auto",
includeEncryptedReasoning: true,
include: ["reasoning.encrypted_content"],
},
},
}),
@@ -407,6 +407,108 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("accepts the full ResponseIncludable union", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
prompt: "hi",
providerOptions: {
openai: {
include: ["reasoning.encrypted_content", "code_interpreter_call.outputs", "web_search_call.results"],
},
},
}),
)
expect(prepared.body.include).toEqual([
"reasoning.encrypted_content",
"code_interpreter_call.outputs",
"web_search_call.results",
])
}),
)
it.effect("filters unknown includable values out of the include array", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
prompt: "hi",
// The user passed one invalid entry alongside a valid one. Keep the
// valid one so the request still succeeds rather than failing on a
// typo from upstream config.
providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
}),
)
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
}),
)
it.effect("treats an explicit empty include as no include at all", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }),
)
expect(prepared.body.include).toBeUndefined()
}),
)
it.effect("treats an all-invalid include as no include at all", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
)
expect(prepared.body.include).toBeUndefined()
}),
)
it.effect("omits include when no include is set", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }),
)
expect(prepared.body.include).toBeUndefined()
}),
)
it.effect("requests encrypted reasoning by default for GPT-5 reasoning models", () =>
Effect.gen(function* () {
// The native OpenAI facade configures GPT-5 stateless (store: false) with
// reasoningSummary: "auto" by default. Without `include`, a follow-up
// turn cannot replay reasoning state, so the facade also opts into
// `reasoning.encrypted_content` automatically.
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
prompt: "hi",
}),
)
expect(prepared.body.store).toBe(false)
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.reasoning).toEqual({ effort: "medium", summary: "auto" })
}),
)
it.effect("lets callers opt out of the GPT-5 default include", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
prompt: "hi",
providerOptions: { openai: { include: [] } },
}),
)
expect(prepared.body.include).toBeUndefined()
}),
)
it.effect("request OpenAI provider options override route defaults", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
@@ -547,6 +649,94 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("streams each reasoning summary part as a separate block", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.updateRequest(request, { providerOptions: { openai: { store: false } } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("FirstSecond")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
{
type: "reasoning-end",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{ type: "step-finish", index: 0, reason: "stop" },
{ type: "finish", reason: "stop" },
])
}),
)
it.effect("closes reasoning summary parts when storage is not disabled", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
])
}),
)
it.effect("continues a stateless reasoning conversation", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
@@ -570,6 +760,7 @@ describe("OpenAI Responses route", () => {
]),
Message.user("Summarize it."),
],
providerOptions: { openai: { store: false } },
}),
).pipe(
Effect.provide(
@@ -627,6 +818,7 @@ describe("OpenAI Responses route", () => {
{ type: "text", text: "After." },
]),
],
providerOptions: { openai: { store: false } },
}),
)
@@ -643,6 +835,66 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("references stored reasoning items by id", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "Checked the previous diff.",
providerMetadata: { openai: { itemId: "rs_1" } },
},
]),
],
providerOptions: { openai: { store: true } },
}),
)
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
}),
)
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
id: "req_multi_summary_continuation",
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "First",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{
type: "reasoning",
text: "Second",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
]),
],
providerOptions: { openai: { store: false } },
}),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [
{ type: "summary_text", text: "First" },
{ type: "summary_text", text: "Second" },
],
},
])
}),
)
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
+1 -1
View File
@@ -158,7 +158,7 @@ const normalizeImageText = (value: string) =>
const encryptedReasoningOptions = {
openai: {
store: false,
includeEncryptedReasoning: true,
include: ["reasoning.encrypted_content"],
reasoningEffort: "low",
reasoningSummary: "auto",
},
+75
View File
@@ -4,6 +4,7 @@ import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice }
import { Auth, LLMClient } from "../src/route"
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
import * as OpenAIChat from "../src/protocols/openai-chat"
import * as OpenAIResponses from "../src/protocols/openai-responses"
import { tool, ToolFailure, type ToolExecuteContext } from "../src/tool"
import { ToolRuntime } from "../src/tool-runtime"
import { it } from "./lib/effect"
@@ -309,6 +310,80 @@ describe("LLMClient tools", () => {
}),
)
it.effect("replays encrypted OpenAI reasoning items with tool outputs", () =>
Effect.gen(function* () {
const bodies: unknown[] = []
const layer = dynamicResponse((input) =>
Effect.sync(() => {
bodies.push(decodeJson(input.text))
return input.respond(
bodies.length === 1
? sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{
type: "response.output_item.added",
item: {
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "get_weather",
arguments: "",
},
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"city":"Paris"}' },
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "get_weather",
arguments: '{"city":"Paris"}',
},
},
{ type: "response.completed", response: {} },
)
: sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Done." },
{ type: "response.completed", response: {} },
),
{ headers: { "content-type": "text/event-stream" } },
)
}),
)
yield* TestToolRuntime.runTools({
request: LLM.request({
model: OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-5.5" }),
prompt: "Use the tool.",
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
}),
tools: { get_weather },
}).pipe(Stream.runCollect, Effect.provide(layer))
expect(bodies[1]).toMatchObject({
include: ["reasoning.encrypted_content"],
input: [
{ role: "user" },
{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" },
{ type: "function_call", call_id: "call_1", name: "get_weather" },
{ type: "function_call_output", call_id: "call_1" },
],
})
}),
)
it.effect("emits tool-error for unknown tools so the model can self-correct", () =>
Effect.gen(function* () {
const layer = scriptedResponses([
+3 -3
View File
@@ -74,15 +74,15 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.21.0",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/amazon-bedrock": "4.0.107",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/google": "3.0.75",
"@ai-sdk/google-vertex": "4.0.131",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -509,7 +509,11 @@ export function Prompt(props: PromptProps) {
const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text")
const value = text
const content = await Editor.open({ value, renderer })
const content = await Editor.open({
value,
renderer,
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
})
if (!content) return
input.setText(content)
@@ -6,13 +6,14 @@ export type ThinkingMode = "show" | "hide"
const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
// OpenAI's Responses API surfaces reasoning summaries that start with a bolded
// title line: "**Inspecting PR workflow**\n\n<body>". GitHub Copilot routes
// through the same shape, and the opencode provider relays it too. Pull the
// title out for a nicer label; return null for providers that don't follow
// this convention so the caller can fall back to a generic "Thinking" string.
export function reasoningTitle(text: string): string | null {
const match = text.trimStart().match(/^\*\*([^*\n]+)\*\*/)
return match ? match[1].trim() : null
// title block: "**Inspecting PR workflow**\n\n<body>". Treat that first block,
// or a complete title still awaiting its body while streaming, as disclosure
// metadata so the TUI can style its header independently from the markdown body.
export function reasoningSummary(text: string) {
const content = text.trim()
const match = content.match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/)
if (!match) return { title: null, body: content }
return { title: match[1].trim(), body: content.slice(match[0].length).trimEnd() }
}
export function isThinkingMode(value: unknown): value is ThinkingMode {
@@ -5,9 +5,9 @@ import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { useTheme } from "@tui/context/theme"
import { useLocal } from "@tui/context/local"
import { reasoningTitle, useThinkingMode } from "@tui/context/thinking"
import { reasoningSummary, useThinkingMode } from "@tui/context/thinking"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
import { RGBA, TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
import { useBindings } from "../../keymap"
import { Locale } from "@/util/locale"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
@@ -398,7 +398,7 @@ function AssistantReasoning(props: {
// v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
// in the v2 SDK); we settle on parent-message completion instead.
const isDone = createMemo(() => props.completedAt() !== undefined)
const title = createMemo(() => reasoningTitle(content()))
const summary = createMemo(() => reasoningSummary(content()))
const toggle = () => {
if (!inMinimal()) return
@@ -407,41 +407,50 @@ function AssistantReasoning(props: {
return (
<Show when={content()}>
<Switch>
<Match when={!inMinimal() || expanded()}>
<box paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0} onMouseUp={toggle}>
<box paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}>
<box onMouseUp={toggle}>
<ReasoningHeader
toggleable={inMinimal()}
open={!inMinimal() || expanded()}
done={isDone()}
title={summary().title}
/>
</box>
<Show when={(!inMinimal() || expanded()) && summary().body}>
<box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={props.subtleSyntax}
content={(inMinimal() ? "- " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
content={summary().body}
conceal={true}
fg={theme.textMuted}
/>
</box>
</Match>
<Match when={isDone()}>
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<CollapsedReasoningText title={title()} />
</box>
</Match>
<Match when={true}>
<box paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
</box>
</Match>
</Switch>
</Show>
</box>
</Show>
)
}
function CollapsedReasoningText(props: { title: string | null }) {
function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: boolean; title: string | null }) {
const { theme } = useTheme()
const fg = () =>
props.open
? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
: theme.warning
return (
<text fg={theme.warning} wrapMode="none">
<span style={{ fg: theme.warning, italic: true }}>{props.title ? "+ Thought: " + props.title : "+ Thought"}</span>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>{props.done ? "Thought" : "Thinking"}</span>
<Show when={props.title}>
<span>: </span>
<span>{props.title}</span>
</Show>
</text>
)
}
@@ -81,7 +81,7 @@ import * as Model from "../../util/model"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { nextThinkingMode, reasoningSummary, useThinkingMode, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
@@ -965,7 +965,11 @@ export function Session() {
if (options.openWithoutSaving) {
// Just open in editor without saving
await Editor.open({ value: transcript, renderer })
await Editor.open({
value: transcript,
renderer,
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
})
} else {
const exportDir = process.cwd()
const filename = options.filename.trim()
@@ -974,7 +978,11 @@ export function Session() {
await Filesystem.write(filepath, transcript)
// Open with EDITOR if available
const result = await Editor.open({ value: transcript, renderer })
const result = await Editor.open({
value: transcript,
renderer,
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
})
if (result !== undefined) {
await Filesystem.write(filepath, result)
}
@@ -1515,12 +1523,8 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
const end = props.part.time.end
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
})
// OpenAI / Copilot / opencode-via-OpenAI emit `**Title**\n\n<body>` summary
// blocks. Surface the title both while streaming and after settling so the
// collapsed line carries real signal, not just a duration.
const title = createMemo(() => reasoningTitle(content()))
// Keep markdown emphasis for the existing thinking color/concealment, but render it without italics.
const syntax = createMemo(() => generateSubtleSyntax(theme, { "markup.italic": { italic: false } }))
const summary = createMemo(() => reasoningSummary(content()))
const syntax = createMemo(() => generateSubtleSyntax(theme))
const toggle = () => {
if (!inMinimal()) return
@@ -1529,47 +1533,65 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
return (
<Show when={content()}>
<Switch>
<Match when={!inMinimal() || expanded()}>
{/* Full markdown block: `show` mode, or `hide` after the user opens it. */}
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexDirection="column" onMouseUp={toggle}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}>
<box onMouseUp={toggle}>
<ReasoningHeader
toggleable={inMinimal()}
open={!inMinimal() || expanded()}
done={isDone()}
title={summary().title}
duration={isDone() ? Locale.duration(duration()) : undefined}
/>
</box>
<Show when={(!inMinimal() || expanded()) && summary().body}>
<box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
// `_Thinking:_`/`_Thought:_` still drives markdown emphasis color and conceals the underscores;
// the syntax override above removes only the italic attribute from that emphasis token.
content={(inMinimal() ? "- " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
content={summary().body}
conceal={ctx.conceal()}
fg={theme.textMuted}
/>
</box>
</Match>
<Match when={isDone()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<CollapsedReasoningText title={title()} duration={duration()} />
</box>
</Match>
<Match when={true}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0} onMouseUp={toggle}>
<Spinner color={theme.textMuted}>{title() ? "Thinking: " + title() : "Thinking"}</Spinner>
</box>
</Match>
</Switch>
</Show>
</box>
</Show>
)
}
function CollapsedReasoningText(props: { title: string | null; duration: number }) {
function ReasoningHeader(props: {
toggleable: boolean
open: boolean
done: boolean
title: string | null
duration?: string
}) {
const { theme } = useTheme()
const duration = () => Locale.duration(props.duration)
const fg = () =>
props.open
? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
: theme.warning
return (
<text fg={theme.warning} wrapMode="none">
<span style={{ fg: theme.warning }}>
{props.title ? "+ Thought: " + props.title + " · " + duration() : "+ Thought: " + duration()}
</span>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>{props.done ? "Thought" : "Thinking"}</span>
<Show when={props.title || props.duration}>
<span>: </span>
</Show>
<Show when={props.title}>
<span>{props.title}</span>
</Show>
<Show when={props.duration}>
<span>
{props.title ? " · " : ""}
{props.duration}
</span>
</Show>
</text>
)
}
@@ -6,7 +6,7 @@ import { CliRenderer } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
export async function open(opts: { value: string; renderer: CliRenderer; cwd?: string }): Promise<string | undefined> {
const editor = process.env["VISUAL"] || process.env["EDITOR"]
if (!editor) return
@@ -19,6 +19,7 @@ export async function open(opts: { value: string; renderer: CliRenderer }): Prom
try {
const parts = editor.split(" ")
const proc = Process.spawn([...parts, filepath], {
cwd: opts.cwd,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
@@ -50,7 +50,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
experimentalNativeLlm: enabledByExperimental("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
}) {}
+5 -3
View File
@@ -1,6 +1,5 @@
import { and } from "drizzle-orm"
import { and, eq, sql } from "drizzle-orm"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
import { ProjectTable } from "./project.sql"
import { PermissionTable, SessionTable } from "../session/session.sql"
import { WorkspaceTable } from "../control-plane/workspace.sql"
@@ -220,7 +219,10 @@ export const layer = Layer.effect(
d.update(PermissionTable).set({ project_id: newID }).where(eq(PermissionTable.project_id, oldID)).run()
}
d.update(SessionTable).set({ project_id: newID }).where(eq(SessionTable.project_id, oldID)).run()
d.update(SessionTable)
.set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` })
.where(eq(SessionTable.project_id, oldID))
.run()
d.update(WorkspaceTable).set({ project_id: newID }).where(eq(WorkspaceTable.project_id, oldID)).run()
if (oldProject) d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run()
+12 -4
View File
@@ -17,6 +17,11 @@ function mimeToModality(mime: string): Modality | undefined {
export const OUTPUT_TOKEN_MAX = 32_000
// OpenAI Responses `include` value that returns the encrypted reasoning state
// needed for stateless multi-turn reasoning (store: false). Hoisted so every
// branch that requests it stays in lockstep.
const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const
export function sanitizeSurrogates(content: string) {
return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
}
@@ -756,7 +761,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
include: INCLUDE_ENCRYPTED_REASONING,
},
]),
)
@@ -790,7 +795,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
include: INCLUDE_ENCRYPTED_REASONING,
},
]),
)
@@ -803,7 +808,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
include: INCLUDE_ENCRYPTED_REASONING,
},
]),
)
@@ -1134,6 +1139,9 @@ export function options(input: {
if (!input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium"
result["reasoningSummary"] = "auto"
if (input.model.api.npm === "@ai-sdk/openai") {
result["include"] = INCLUDE_ENCRYPTED_REASONING
}
}
// Only set textVerbosity for non-chat gpt-5.x models
@@ -1149,7 +1157,7 @@ export function options(input: {
if (input.model.providerID.startsWith("opencode")) {
result["promptCacheKey"] = input.sessionID
result["include"] = ["reasoning.encrypted_content"]
result["include"] = INCLUDE_ENCRYPTED_REASONING
result["reasoningSummary"] = "auto"
}
}
+85 -30
View File
@@ -1,4 +1,4 @@
import { Deferred, Effect, Layer, Schema, Context } from "effect"
import { Cache, Context, Deferred, Duration, Effect, Layer, Schema } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
@@ -75,6 +75,19 @@ export const Reply = Schema.Struct({
}).annotate({ identifier: "QuestionReply" })
export type Reply = Schema.Schema.Type<typeof Reply>
export const Result = Schema.Union([
Schema.Struct({
status: Schema.Literal("answered"),
answers: Schema.Array(Answer).annotate({
description: "User answers in order of questions (each answer is an array of selected labels)",
}),
}),
Schema.Struct({
status: Schema.Literal("rejected"),
}),
]).annotate({ discriminator: "status", identifier: "QuestionResult" })
export type Result = Schema.Schema.Type<typeof Result>
const Replied = Schema.Struct({
sessionID: SessionID,
requestID: QuestionID,
@@ -104,21 +117,28 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Que
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
deferred: Deferred.Deferred<Result>
}
interface State {
pending: Map<QuestionID, PendingEntry>
completed: Cache.Cache<QuestionID, Result, NotFoundError>
}
// Service
export interface Interface {
readonly create: (input: {
sessionID: SessionID
questions: ReadonlyArray<Info>
tool?: Tool
}) => Effect.Effect<Request>
readonly ask: (input: {
sessionID: SessionID
questions: ReadonlyArray<Info>
tool?: Tool
}) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
readonly wait: (requestID: QuestionID) => Effect.Effect<Result, NotFoundError>
readonly reply: (input: {
requestID: QuestionID
answers: ReadonlyArray<Answer>
@@ -137,12 +157,17 @@ export const layer = Layer.effect(
Effect.fn("Question.state")(function* () {
const state = {
pending: new Map<QuestionID, PendingEntry>(),
completed: yield* Cache.make<QuestionID, Result, NotFoundError>({
capacity: 10_000,
timeToLive: Duration.minutes(30),
lookup: (requestID) => Effect.fail(new NotFoundError({ requestID })),
}),
}
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const item of state.pending.values()) {
yield* Deferred.fail(item.deferred, new RejectedError())
yield* Deferred.succeed(item.deferred, { status: "rejected" })
}
state.pending.clear()
}),
@@ -152,7 +177,7 @@ export const layer = Layer.effect(
}),
)
const ask = Effect.fn("Question.ask")(function* (input: {
const create = Effect.fn("Question.create")(function* (input: {
sessionID: SessionID
questions: ReadonlyArray<Info>
tool?: Tool
@@ -161,7 +186,7 @@ export const layer = Layer.effect(
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
const deferred = yield* Deferred.make<Result>()
const info: Request = {
id,
sessionID: input.sessionID,
@@ -170,49 +195,79 @@ export const layer = Layer.effect(
}
pending.set(id, { info, deferred })
yield* bus.publish(Event.Asked, info)
return info
})
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
const wait = Effect.fn("Question.wait")(function* (requestID: QuestionID) {
const info = yield* InstanceState.get(state)
const pending = info.pending.get(requestID)
if (pending) return yield* Deferred.await(pending.deferred)
return yield* Cache.get(info.completed, requestID).pipe(
Effect.tapError(() => Cache.invalidateWhen(info.completed, requestID, () => true)),
)
})
const settle = Effect.fn("Question.settle")(function* (requestID: QuestionID, result: Result) {
const info = yield* InstanceState.get(state)
const existing = info.pending.get(requestID)
if (!existing) return false
yield* Cache.set(info.completed, requestID, result)
info.pending.delete(requestID)
if (result.status === "answered") {
yield* bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: result.answers,
})
} else {
yield* bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
}
yield* Deferred.succeed(existing.deferred, result)
return true
})
const ask = Effect.fn("Question.ask")(function* (input: {
sessionID: SessionID
questions: ReadonlyArray<Info>
tool?: Tool
}) {
const request = yield* create(input)
const result = yield* Effect.ensuring(
wait(request.id).pipe(Effect.orDie),
settle(request.id, { status: "rejected" }).pipe(Effect.asVoid),
)
if (result.status === "answered") return result.answers
return yield* new RejectedError()
})
const reply = Effect.fn("Question.reply")(function* (input: {
requestID: QuestionID
answers: ReadonlyArray<Answer>
}) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(input.requestID)
if (!existing) {
const result: Result = {
status: "answered",
answers: input.answers.map((a) => [...a]),
}
const ok = yield* settle(input.requestID, result)
if (!ok) {
log.warn("reply for unknown request", { requestID: input.requestID })
return yield* new NotFoundError({ requestID: input.requestID })
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
yield* bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers.map((a) => [...a]),
})
yield* Deferred.succeed(existing.deferred, input.answers)
})
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(requestID)
if (!existing) {
const ok = yield* settle(requestID, { status: "rejected" })
if (!ok) {
log.warn("reject for unknown request", { requestID })
return yield* new NotFoundError({ requestID })
}
pending.delete(requestID)
log.info("rejected", { requestID })
yield* bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
yield* Deferred.fail(existing.deferred, new RejectedError())
})
const list = Effect.fn("Question.list")(function* () {
@@ -220,7 +275,7 @@ export const layer = Layer.effect(
return Array.from(pending.values(), (x) => x.info)
})
return Service.of({ ask, reply, reject, list })
return Service.of({ create, ask, wait, reply, reject, list })
}),
)
@@ -1,5 +1,6 @@
import { Question } from "@/question"
import { QuestionID } from "@/question/schema"
import { SessionID } from "@/session/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { QuestionNotFoundError } from "../errors"
@@ -15,10 +16,32 @@ const ReplyPayload = Schema.Struct({
}),
})
export const AskPayload = Schema.Struct({
sessionID: SessionID,
questions: Schema.Array(Question.Info).annotate({
description: "Questions to ask",
}),
})
const AskResponse = Schema.Struct({
id: QuestionID,
})
export const QuestionApi = HttpApi.make("question")
.add(
HttpApiGroup.make("question")
.add(
HttpApiEndpoint.post("ask", root, {
query: WorkspaceRoutingQuery,
payload: AskPayload,
success: described(AskResponse, "Created question request"),
}).annotateMerge(
OpenApi.annotations({
identifier: "question.ask",
summary: "Ask questions",
description: "Create a question request and return its id.",
}),
),
HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Question.Request), "List of pending questions"),
@@ -29,6 +52,18 @@ export const QuestionApi = HttpApi.make("question")
description: "Get all pending question requests across all sessions.",
}),
),
HttpApiEndpoint.get("wait", `${root}/:requestID/wait`, {
params: { requestID: QuestionID },
query: WorkspaceRoutingQuery,
success: described(Question.Result, "Question result"),
error: [QuestionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "question.wait",
summary: "Wait for question result",
description: "Wait for a question request to be answered or rejected.",
}),
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: QuestionID },
query: WorkspaceRoutingQuery,
@@ -4,6 +4,7 @@ import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { QuestionNotFoundError } from "../errors"
import { AskPayload } from "../groups/question"
export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question", (handlers) =>
Effect.gen(function* () {
@@ -13,6 +14,24 @@ export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question"
return yield* svc.list()
})
const ask = Effect.fn("QuestionHttpApi.ask")(function* (ctx: { payload: typeof AskPayload.Type }) {
const request = yield* svc.create(ctx.payload)
return { id: request.id }
})
const wait = Effect.fn("QuestionHttpApi.wait")(function* (ctx: { params: { requestID: QuestionID } }) {
return yield* svc.wait(ctx.params.requestID).pipe(
Effect.catchTag("Question.NotFoundError", (error) =>
Effect.fail(
new QuestionNotFoundError({
requestID: String(error.requestID),
message: `Question request not found: ${error.requestID}`,
}),
),
),
)
})
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
params: { requestID: QuestionID }
payload: Question.Reply
@@ -49,6 +68,11 @@ export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question"
return true
})
return handlers.handle("list", list).handle("reply", reply).handle("reject", reject)
return handlers
.handle("ask", ask)
.handle("list", list)
.handle("wait", wait)
.handle("reply", reply)
.handle("reject", reject)
}),
)
@@ -70,6 +70,14 @@ export function stream(input: StreamInput): StreamResult {
// Integration point with @opencode-ai/llm: native-request lowers session data
// into an LLMRequest, then LLMClient handles route selection and transport.
//
// ProviderTransform.providerOptions builds AI-SDK-shaped options for the
// selected SDK key (e.g. "openai") and the native LLM SDK reads the same
// keys via OpenAIOptions.* (store, reasoningEffort, reasoningSummary,
// include, textVerbosity, promptCacheKey). Both sides intentionally use
// OpenAI's official wire field names, so this is identity, not translation
// — if a field ever needs to differ between the two surfaces, the
// translation belongs here, not split across both packages.
const stream = input.llmClient.stream({
request: LLMNative.request({
model: input.model,
-3
View File
@@ -16,7 +16,6 @@ import * as Log from "@opencode-ai/core/util/log"
import { Discovery } from "./discovery"
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" }
import { isRecord } from "@/util/record"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
const log = Log.create({ service: "skill" })
const CLAUDE_EXTERNAL_DIR = ".claude"
@@ -244,8 +243,6 @@ const loadSkills = Effect.fnUntraced(function* (state: State, discovered: Discov
export class Service extends Context.Service<Service, Interface>()("@opencode/Skill") {}
export const use = serviceUse(Service)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
-3
View File
@@ -55,7 +55,6 @@ import { Reference } from "@/reference/reference"
import { BackgroundJob } from "@/background/job"
import { SessionStatus } from "@/session/status"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
const log = Log.create({ service: "tool.registry" })
@@ -82,8 +81,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
export const use = serviceUse(Service)
export const layer: Layer.Layer<
Service,
never,
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { reasoningSummary } from "../../../src/cli/cmd/tui/context/thinking"
describe("reasoningSummary", () => {
test("extracts a leading summary title and leaves markdown body", () => {
expect(reasoningSummary("**Continuing Quality Review**\n\nDetails.\n\n**Next section**\n\nMore.")).toEqual({
title: "Continuing Quality Review",
body: "Details.\n\n**Next section**\n\nMore.",
})
})
test("extracts a completed title before its streamed body arrives", () => {
expect(reasoningSummary("**Continuing Quality Review**")).toEqual({
title: "Continuing Quality Review",
body: "",
})
})
test("preserves markdown-significant indentation in the extracted body", () => {
expect(reasoningSummary("**Continuing Quality Review**\n\n const value = true\n")).toEqual({
title: "Continuing Quality Review",
body: " const value = true",
})
})
test("does not consume ordinary leading bold content", () => {
expect(reasoningSummary("**Important:** keep this in the body.")).toEqual({
title: null,
body: "**Important:** keep this in the body.",
})
})
test("leaves content without a leading title in its body", () => {
expect(reasoningSummary("Details only.")).toEqual({ title: null, body: "Details only." })
})
})
@@ -62,7 +62,7 @@ describe("RuntimeFlags", () => {
expect(flags.experimentalEventSystem).toBe(true)
expect(flags.experimentalWorkspaces).toBe(true)
expect(flags.experimentalIconDiscovery).toBe(true)
expect(flags.experimentalNativeLlm).toBe(true)
expect(flags.experimentalNativeLlm).toBe(false)
expect(flags.client).toBe("desktop")
}),
)
@@ -81,13 +81,13 @@ describe("RuntimeFlags", () => {
}),
)
it.effect("enables native LLM via dedicated or umbrella flag", () =>
it.effect("enables native LLM via dedicated flag only", () =>
Effect.gen(function* () {
const explicit = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_NATIVE_LLM: "true" })))
const umbrella = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL: "true" })))
expect(explicit.experimentalNativeLlm).toBe(true)
expect(umbrella.experimentalNativeLlm).toBe(true)
expect(umbrella.experimentalNativeLlm).toBe(false)
}),
)
+75 -63
View File
@@ -3,6 +3,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { $ } from "bun"
import { Cause, Effect, Exit, Layer } from "effect"
import path from "path"
import fs from "fs/promises"
import { File } from "../../src/file"
import { disposeAllInstances, TestInstance, withTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -47,11 +48,6 @@ const gitAddAll = (directory: string) => Effect.promise(() => $`git add .`.cwd(d
const gitCommit = (directory: string, message: string) =>
Effect.promise(() => $`git commit -m ${message}`.cwd(directory).quiet())
const writeFixtureFile = (directory: string, file: string, content: string | Uint8Array) =>
AppFileSystem.use.writeWithDirs(path.join(directory, file), content)
const removeFixtureFile = (directory: string, file: string) => AppFileSystem.use.remove(path.join(directory, file))
const failureMessage = <A, E, R>(self: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const exit = yield* self.pipe(Effect.exit)
@@ -76,7 +72,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("reads text file via Filesystem.readText()", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "test.txt", "Hello World")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "Hello World", "utf-8"))
const result = yield* read("test.txt")
expect(result.type).toBe("text")
@@ -95,7 +91,9 @@ describe("file/index Filesystem patterns", () => {
it.instance("trims whitespace from text content", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "test.txt", " content with spaces \n\n")
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "test.txt"), " content with spaces \n\n", "utf-8"),
)
const result = yield* read("test.txt")
expect(result.content).toBe("content with spaces")
@@ -105,7 +103,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("handles empty text file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "empty.txt", "")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "empty.txt"), "", "utf-8"))
const result = yield* read("empty.txt")
expect(result.type).toBe("text")
@@ -116,7 +114,9 @@ describe("file/index Filesystem patterns", () => {
it.instance("handles multi-line text files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "multiline.txt", "line1\nline2\nline3")
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "multiline.txt"), "line1\nline2\nline3", "utf-8"),
)
const result = yield* read("multiline.txt")
expect(result.content).toBe("line1\nline2\nline3")
@@ -129,7 +129,7 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* writeFixtureFile(test.directory, "image.png", binaryContent)
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "image.png"), binaryContent))
const result = yield* read("image.png")
expect(result.type).toBe("text")
@@ -142,7 +142,9 @@ describe("file/index Filesystem patterns", () => {
it.instance("returns empty for binary non-image files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "binary.so", Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "binary.so"), Buffer.from([0x7f, 0x45, 0x4c, 0x46])),
)
const result = yield* read("binary.so")
expect(result.type).toBe("binary")
@@ -156,7 +158,7 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "test.json")
yield* AppFileSystem.use.writeWithDirs(filepath, '{"key": "value"}')
yield* Effect.promise(() => fs.writeFile(filepath, '{"key": "value"}', "utf-8"))
expect(AppFileSystem.mimeType(filepath)).toContain("application/json")
@@ -177,7 +179,7 @@ describe("file/index Filesystem patterns", () => {
for (const testCase of testCases) {
const filepath = path.join(test.directory, `test.${testCase.ext}`)
yield* AppFileSystem.use.writeWithDirs(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]))
yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00])))
expect(AppFileSystem.mimeType(filepath)).toContain(testCase.mime)
}
}),
@@ -286,7 +288,9 @@ describe("file/index Filesystem patterns", () => {
it.instance("treats .ts files as text", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "test.ts", "export const value = 1")
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "test.ts"), "export const value = 1", "utf-8"),
)
const result = yield* read("test.ts")
expect(result.type).toBe("text")
@@ -297,7 +301,9 @@ describe("file/index Filesystem patterns", () => {
it.instance("treats .mts files as text", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "test.mts", "export const value = 1")
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "test.mts"), "export const value = 1", "utf-8"),
)
const result = yield* read("test.mts")
expect(result.type).toBe("text")
@@ -308,7 +314,9 @@ describe("file/index Filesystem patterns", () => {
it.instance("treats .sh files as text", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "test.sh", "#!/usr/bin/env bash\necho hello")
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "test.sh"), "#!/usr/bin/env bash\necho hello", "utf-8"),
)
const result = yield* read("test.sh")
expect(result.type).toBe("text")
@@ -319,7 +327,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("treats Dockerfile as text", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "Dockerfile", "FROM alpine:3.20")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "Dockerfile"), "FROM alpine:3.20", "utf-8"))
const result = yield* read("Dockerfile")
expect(result.type).toBe("text")
@@ -330,7 +338,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("returns encoding info for text files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "test.txt", "simple text")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "simple text", "utf-8"))
const result = yield* read("test.txt")
expect(result.encoding).toBeUndefined()
@@ -341,7 +349,9 @@ describe("file/index Filesystem patterns", () => {
it.instance("returns base64 encoding for images", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "test.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xe0]))
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "test.jpg"), Buffer.from([0xff, 0xd8, 0xff, 0xe0])),
)
const result = yield* read("test.jpg")
expect(result.encoding).toBe("base64")
@@ -371,10 +381,10 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* AppFileSystem.use.writeWithDirs(filepath, "original\n")
yield* Effect.promise(() => fs.writeFile(filepath, "original\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* AppFileSystem.use.writeWithDirs(filepath, "modified\nextra line\n")
yield* Effect.promise(() => fs.writeFile(filepath, "modified\nextra line\n", "utf-8"))
const result = yield* status()
const entry = result.find((file) => file.path === "file.txt")
@@ -391,7 +401,9 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "new.txt", "line1\nline2\nline3\n")
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "new.txt"), "line1\nline2\nline3\n", "utf-8"),
)
const result = yield* status()
const entry = result.find((file) => file.path === "new.txt")
@@ -409,10 +421,10 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "gone.txt")
yield* AppFileSystem.use.writeWithDirs(filepath, "content\n")
yield* Effect.promise(() => fs.writeFile(filepath, "content\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* AppFileSystem.use.remove(filepath)
yield* Effect.promise(() => fs.rm(filepath))
const result = yield* status()
const entries = result.filter((file) => file.path === "gone.txt")
@@ -426,14 +438,14 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "keep.txt", "keep\n")
yield* writeFixtureFile(test.directory, "remove.txt", "remove\n")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "keep\n", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "remove.txt"), "remove\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "initial")
yield* writeFixtureFile(test.directory, "keep.txt", "changed\n")
yield* removeFixtureFile(test.directory, "remove.txt")
yield* writeFixtureFile(test.directory, "brand-new.txt", "hello\n")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "changed\n", "utf-8"))
yield* Effect.promise(() => fs.rm(path.join(test.directory, "remove.txt")))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "brand-new.txt"), "hello\n", "utf-8"))
const result = yield* status()
expect(result.some((file) => file.path === "keep.txt" && file.status === "modified")).toBe(true)
@@ -464,15 +476,13 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "data.bin")
yield* AppFileSystem.use.writeWithDirs(
filepath,
Buffer.from(Array.from({ length: 256 }, (_, index) => index)),
yield* Effect.promise(() =>
fs.writeFile(filepath, Buffer.from(Array.from({ length: 256 }, (_, index) => index))),
)
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add binary")
yield* AppFileSystem.use.writeWithDirs(
filepath,
Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256)),
yield* Effect.promise(() =>
fs.writeFile(filepath, Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256))),
)
const result = yield* status()
@@ -492,9 +502,11 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "subdir"))
yield* writeFixtureFile(test.directory, "file.txt", "content")
yield* writeFixtureFile(test.directory, path.join("subdir", "nested.txt"), "nested")
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "subdir")))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "content", "utf-8"))
yield* Effect.promise(() =>
fs.writeFile(path.join(test.directory, "subdir", "nested.txt"), "nested", "utf-8"),
)
const nodes = yield* list()
expect(nodes.length).toBeGreaterThanOrEqual(2)
@@ -515,10 +527,10 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "beta"))
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "alpha"))
yield* writeFixtureFile(test.directory, "zz.txt", "")
yield* writeFixtureFile(test.directory, "aa.txt", "")
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "beta")))
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "alpha")))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "zz.txt"), "", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "aa.txt"), "", "utf-8"))
const nodes = yield* list()
const dirs = nodes.filter((node) => node.type === "directory")
@@ -539,8 +551,8 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, ".DS_Store", "")
yield* writeFixtureFile(test.directory, "visible.txt", "")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".DS_Store"), "", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "visible.txt"), "", "utf-8"))
const names = (yield* list()).map((node) => node.name)
expect(names).not.toContain(".git")
@@ -555,10 +567,10 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, ".gitignore", "*.log\nbuild/\n")
yield* writeFixtureFile(test.directory, "app.log", "log data")
yield* writeFixtureFile(test.directory, "main.ts", "code")
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "build"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".gitignore"), "*.log\nbuild/\n", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "app.log"), "log data", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "main.ts"), "code", "utf-8"))
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "build")))
const nodes = yield* list()
expect(nodes.find((node) => node.name === "app.log")?.ignored).toBe(true)
@@ -573,9 +585,9 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "sub"))
yield* writeFixtureFile(test.directory, path.join("sub", "a.txt"), "")
yield* writeFixtureFile(test.directory, path.join("sub", "b.txt"), "")
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "sub")))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "a.txt"), "", "utf-8"))
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "b.txt"), "", "utf-8"))
const nodes = yield* list("sub")
expect(nodes.length).toBe(2)
@@ -597,7 +609,7 @@ describe("file/index Filesystem patterns", () => {
it.instance("works without git", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "file.txt", "hi")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "hi", "utf-8"))
const nodes = yield* list()
expect(nodes.length).toBeGreaterThanOrEqual(1)
@@ -743,7 +755,7 @@ describe("file/index Filesystem patterns", () => {
yield* init()
expect(yield* search({ query: "fresh", type: "file" })).toEqual([])
yield* writeFixtureFile(test.directory, "fresh.ts", "fresh")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "fresh.ts"), "fresh", "utf-8"))
expect(yield* search({ query: "fresh", type: "file" })).toContain("fresh.ts")
}),
@@ -758,10 +770,10 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* AppFileSystem.use.writeWithDirs(filepath, "original content\n")
yield* Effect.promise(() => fs.writeFile(filepath, "original content\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* AppFileSystem.use.writeWithDirs(filepath, "modified content\n")
yield* Effect.promise(() => fs.writeFile(filepath, "modified content\n", "utf-8"))
const result = yield* read("file.txt")
expect(result.type).toBe("text")
@@ -781,10 +793,10 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "staged.txt")
yield* AppFileSystem.use.writeWithDirs(filepath, "before\n")
yield* Effect.promise(() => fs.writeFile(filepath, "before\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
yield* AppFileSystem.use.writeWithDirs(filepath, "after\n")
yield* Effect.promise(() => fs.writeFile(filepath, "after\n", "utf-8"))
yield* gitAddAll(test.directory)
const result = yield* read("staged.txt")
@@ -800,7 +812,7 @@ describe("file/index Filesystem patterns", () => {
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "clean.txt")
yield* AppFileSystem.use.writeWithDirs(filepath, "unchanged\n")
yield* Effect.promise(() => fs.writeFile(filepath, "unchanged\n", "utf-8"))
yield* gitAddAll(test.directory)
yield* gitCommit(test.directory, "add file")
@@ -820,14 +832,14 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const one = yield* TestInstance
yield* writeFixtureFile(one.directory, "a.ts", "one")
yield* Effect.promise(() => fs.writeFile(path.join(one.directory, "a.ts"), "one", "utf-8"))
yield* init()
expect(yield* search({ query: "a.ts", type: "file" })).toContain("a.ts")
expect(yield* search({ query: "b.ts", type: "file" })).not.toContain("b.ts")
yield* Effect.gen(function* () {
const two = yield* TestInstance
yield* writeFixtureFile(two.directory, "b.ts", "two")
yield* Effect.promise(() => fs.writeFile(path.join(two.directory, "b.ts"), "two", "utf-8"))
yield* init()
expect(yield* search({ query: "b.ts", type: "file" })).toContain("b.ts")
expect(yield* search({ query: "a.ts", type: "file" })).not.toContain("a.ts")
@@ -841,14 +853,14 @@ describe("file/index Filesystem patterns", () => {
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeFixtureFile(test.directory, "before.ts", "before")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "before.ts"), "before", "utf-8"))
yield* init()
expect(yield* search({ query: "before", type: "file" })).toContain("before.ts")
yield* Effect.promise(() => disposeAllInstances())
yield* writeFixtureFile(test.directory, "after.ts", "after")
yield* removeFixtureFile(test.directory, "before.ts")
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "after.ts"), "after", "utf-8"))
yield* Effect.promise(() => fs.rm(path.join(test.directory, "before.ts")))
yield* init()
expect(yield* search({ query: "after", type: "file" })).toContain("after.ts")
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+320 -236
View File
@@ -1,16 +1,11 @@
import { beforeEach, describe, expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { beforeEach, describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import { Effect } from "effect"
import { pollWithTimeout, testEffect } from "../lib/effect"
import { requireInstance, TestInstance } from "../fixture/fixture"
import { tmpdir, withTestInstance } from "../fixture/fixture"
import { LSPClient } from "@/lsp/client"
import * as LSPServer from "@/lsp/server"
import * as Log from "@opencode-ai/core/util/log"
const it = testEffect(AppFileSystem.defaultLayer)
function spawnFakeServer() {
const { spawn } = require("child_process")
const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
@@ -21,164 +16,202 @@ function spawnFakeServer() {
}
}
const createClient = (handle: LSPServer.Handle, initialization?: LSPServer.Handle["initialization"]) =>
Effect.gen(function* () {
const test = yield* TestInstance
const instance = yield* requireInstance
return yield* Effect.promise(() =>
LSPClient.create({
serverID: "fake",
server: initialization ? { ...handle, initialization } : handle,
root: test.directory,
directory: test.directory,
instance,
}),
)
})
const createScopedClient = (handle: LSPServer.Handle, initialization?: LSPServer.Handle["initialization"]) =>
Effect.gen(function* () {
const client = yield* createClient(handle, initialization)
yield* Effect.addFinalizer(() => Effect.promise(() => client.shutdown()).pipe(Effect.ignore))
return client
})
const writeFile = (file: string, content: string) => AppFileSystem.use.writeWithDirs(file, content)
describe("LSPClient interop", () => {
beforeEach(async () => {
await Log.init({ print: true })
})
it.instance("handles workspace/workspaceFolders request", () =>
Effect.gen(function* () {
const client = yield* createScopedClient(spawnFakeServer())
test("handles workspace/workspaceFolders request", async () => {
const handle = spawnFakeServer() as any
yield* Effect.promise(() =>
client.connection.sendNotification("test/trigger", {
method: "workspace/workspaceFolders",
const client = await withTestInstance({
directory: process.cwd(),
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
)
})
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
expect(client.connection).toBeDefined()
}),
)
await client.connection.sendNotification("test/trigger", {
method: "workspace/workspaceFolders",
})
it.instance("handles client/registerCapability request", () =>
Effect.gen(function* () {
const client = yield* createScopedClient(spawnFakeServer())
await new Promise((resolve) => setTimeout(resolve, 100))
expect(client.connection).toBeDefined()
await client.shutdown()
})
yield* Effect.promise(() =>
client.connection.sendNotification("test/trigger", {
method: "client/registerCapability",
test("handles client/registerCapability request", async () => {
const handle = spawnFakeServer() as any
const client = await withTestInstance({
directory: process.cwd(),
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
)
})
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
expect(client.connection).toBeDefined()
}),
)
await client.connection.sendNotification("test/trigger", {
method: "client/registerCapability",
})
it.instance("handles client/unregisterCapability request", () =>
Effect.gen(function* () {
const client = yield* createScopedClient(spawnFakeServer())
await new Promise((resolve) => setTimeout(resolve, 100))
expect(client.connection).toBeDefined()
await client.shutdown()
})
yield* Effect.promise(() =>
client.connection.sendNotification("test/trigger", {
method: "client/unregisterCapability",
test("handles client/unregisterCapability request", async () => {
const handle = spawnFakeServer() as any
const client = await withTestInstance({
directory: process.cwd(),
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
)
})
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
expect(client.connection).toBeDefined()
}),
)
await client.connection.sendNotification("test/trigger", {
method: "client/unregisterCapability",
})
it.instance("initialize does not overclaim unsupported diagnostics capabilities", () =>
Effect.gen(function* () {
const client = yield* createScopedClient(spawnFakeServer())
await new Promise((resolve) => setTimeout(resolve, 100))
expect(client.connection).toBeDefined()
await client.shutdown()
})
const params = yield* Effect.promise(() =>
client.connection.sendRequest<{
capabilities: {
workspace: { diagnostics: { refreshSupport: boolean } }
textDocument: { publishDiagnostics: { versionSupport: boolean } }
}
}>("test/get-initialize-params", {}),
)
expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false)
expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false)
}),
)
test("initialize does not overclaim unsupported diagnostics capabilities", async () => {
const handle = spawnFakeServer() as any
it.instance("workspace/configuration returns one result per requested item", () =>
Effect.gen(function* () {
const initialization = {
alpha: {
beta: 1,
},
gamma: true,
}
const client = yield* createScopedClient(spawnFakeServer(), initialization)
const response = yield* Effect.promise(() =>
client.connection.sendRequest<unknown[]>("test/request-configuration", {
items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}],
const client = await withTestInstance({
directory: process.cwd(),
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
)
})
expect(response).toEqual([{ beta: 1 }, 1, null, initialization])
}),
)
const params = await client.connection.sendRequest<any>("test/get-initialize-params", {})
expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false)
expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false)
it.instance("sends ranged didChange for incremental sync servers", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "client.ts")
yield* writeFile(file, "first\n")
await client.shutdown()
})
const client = yield* createScopedClient(spawnFakeServer())
test("workspace/configuration returns one result per requested item", async () => {
const handle = spawnFakeServer() as any
const initialization = {
alpha: {
beta: 1,
},
gamma: true,
}
yield* Effect.promise(() => client.notify.open({ path: file }))
yield* writeFile(file, "second\nthird\n")
yield* Effect.promise(() => client.notify.open({ path: file }))
const client = await withTestInstance({
directory: process.cwd(),
fn: (ctx) =>
LSPClient.create({
serverID: "fake",
server: {
...(handle as unknown as LSPServer.Handle),
initialization,
},
root: process.cwd(),
directory: process.cwd(),
instance: ctx,
}),
})
const change = yield* Effect.promise(() =>
client.connection.sendRequest<{
const response = await client.connection.sendRequest<any[]>("test/request-configuration", {
items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}],
})
expect(response).toEqual([{ beta: 1 }, 1, null, initialization])
await client.shutdown()
})
test("sends ranged didChange for incremental sync servers", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.ts")
await Bun.write(file, "first\n")
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.notify.open({ path: file })
await Bun.write(file, "second\nthird\n")
await client.notify.open({ path: file })
const change = await client.connection.sendRequest<{
textDocument: { version: number }
contentChanges: {
range?: { start: { line: number; character: number }; end: { line: number; character: number } }
text: string
}[]
}>("test/get-last-change", {}),
)
expect(change.textDocument.version).toBe(1)
expect(change.contentChanges).toEqual([
{
range: {
start: { line: 0, character: 0 },
end: { line: 1, character: 0 },
}>("test/get-last-change", {})
expect(change.textDocument.version).toBe(1)
expect(change.contentChanges).toEqual([
{
range: {
start: { line: 0, character: 0 },
end: { line: 1, character: 0 },
},
text: "second\nthird\n",
},
text: "second\nthird\n",
},
])
}),
)
])
it.instance("document mode falls back to push diagnostics", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "client.ts")
yield* writeFile(file, "const x = 1\n")
await client.shutdown()
},
})
})
const client = yield* createScopedClient(spawnFakeServer())
test("document mode falls back to push diagnostics", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.ts")
await Bun.write(file, "const x = 1\n")
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
const wait = client.waitForDiagnostics({ path: file, version, mode: "document" })
yield* Effect.promise(() =>
client.connection.sendNotification("test/publish-diagnostics", {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
const version = await client.notify.open({ path: file })
const wait = client.waitForDiagnostics({ path: file, version, mode: "document" })
await client.connection.sendNotification("test/publish-diagnostics", {
uri: pathToFileURL(file).href,
version,
diagnostics: [
@@ -191,30 +224,40 @@ describe("LSPClient interop", () => {
severity: 1,
},
],
}),
)
yield* Effect.promise(() => wait)
})
await wait
const diagnostics = client.diagnostics.get(file) ?? []
expect(diagnostics).toHaveLength(1)
expect(diagnostics[0]?.message).toBe("push diagnostic")
const diagnostics = client.diagnostics.get(file) ?? []
expect(diagnostics).toHaveLength(1)
expect(diagnostics[0]?.message).toBe("push diagnostic")
const count = yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
expect(count).toBe(0)
}),
)
const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
expect(count).toBe(0)
it.instance("document mode accepts matching push diagnostics published before waiting", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "client.ts")
yield* writeFile(file, "const x = 1\n")
await client.shutdown()
},
})
})
const client = yield* createScopedClient(spawnFakeServer())
test("document mode accepts matching push diagnostics published before waiting", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.ts")
await Bun.write(file, "const x = 1\n")
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
yield* Effect.promise(() =>
client.connection.sendNotification("test/publish-diagnostics", {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
const version = await client.notify.open({ path: file })
await client.connection.sendNotification("test/publish-diagnostics", {
uri: pathToFileURL(file).href,
version,
diagnostics: [
@@ -227,31 +270,41 @@ describe("LSPClient interop", () => {
severity: 1,
},
],
}),
)
})
const diagnostic = yield* pollWithTimeout(
Effect.sync(() => client.diagnostics.get(file)?.[0]),
"push diagnostic was not published",
)
expect(diagnostic.message).toBe("push diagnostic")
for (let i = 0; i < 20 && (client.diagnostics.get(file)?.length ?? 0) === 0; i++) {
await new Promise((resolve) => setTimeout(resolve, 25))
}
const started = Date.now()
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" }))
expect(Date.now() - started).toBeLessThan(1_000)
}),
)
expect(client.diagnostics.get(file)?.[0]?.message).toBe("push diagnostic")
it.instance("document mode waits for pull diagnostics", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "client.cs")
yield* writeFile(file, "class C {}\n")
const started = Date.now()
await client.waitForDiagnostics({ path: file, version, mode: "document" })
expect(Date.now() - started).toBeLessThan(1_000)
const client = yield* createScopedClient(spawnFakeServer())
await client.shutdown()
},
})
})
yield* Effect.promise(() =>
client.connection.sendRequest("test/configure-pull-diagnostics", {
test("document mode waits for pull diagnostics", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
await Bun.write(file, "class C {}\n")
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registerOn: "didOpen",
registrations: [{ identifier: "DocumentCompilerSemantic" }],
documentDiagnosticsByIdentifier: {
@@ -266,31 +319,41 @@ describe("LSPClient interop", () => {
},
],
},
}),
)
})
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" }))
const version = await client.notify.open({ path: file })
await client.waitForDiagnostics({ path: file, version, mode: "document" })
const diagnostics = client.diagnostics.get(file) ?? []
expect(diagnostics).toHaveLength(1)
expect(diagnostics[0]?.message).toBe("pull diagnostic")
const diagnostics = client.diagnostics.get(file) ?? []
expect(diagnostics).toHaveLength(1)
expect(diagnostics[0]?.message).toBe("pull diagnostic")
const count = yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
expect(count).toBeGreaterThan(0)
}),
)
const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
expect(count).toBeGreaterThan(0)
it.instance("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "client.cs")
yield* writeFile(file, "class C {}\n")
await client.shutdown()
},
})
})
const client = yield* createScopedClient(spawnFakeServer())
test("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
await Bun.write(file, "class C {}\n")
yield* Effect.promise(() =>
client.connection.sendRequest("test/configure-pull-diagnostics", {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registrations: [{ identifier: "fast" }, { identifier: "slow" }],
documentDiagnosticsByIdentifier: {
fast: [
@@ -308,34 +371,43 @@ describe("LSPClient interop", () => {
documentDelayMsByIdentifier: {
slow: 2_500,
},
}),
)
})
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
yield* Effect.promise(() => client.connection.sendRequest("test/register-configured-pull-diagnostics", {}))
const started = Date.now()
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" }))
const version = await client.notify.open({ path: file })
await client.connection.sendRequest("test/register-configured-pull-diagnostics", {})
await new Promise((resolve) => setTimeout(resolve, 100))
const started = Date.now()
await client.waitForDiagnostics({ path: file, version, mode: "document" })
expect(Date.now() - started).toBeLessThan(1_000)
expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic")
expect(
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {})),
).toBeGreaterThan(1)
}),
)
expect(Date.now() - started).toBeLessThan(1_000)
expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic")
expect(await client.connection.sendRequest("test/get-diagnostic-request-count", {})).toBeGreaterThan(1)
it.instance("full mode includes workspace pull diagnostics", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "client.cs")
const related = path.join(test.directory, "other.cs")
yield* writeFile(file, "class C {}\n")
yield* writeFile(related, "class D {}\n")
await client.shutdown()
},
})
})
const client = yield* createScopedClient(spawnFakeServer())
test("full mode includes workspace pull diagnostics", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
const related = path.join(tmp.path, "other.cs")
await Bun.write(file, "class C {}\n")
await Bun.write(related, "class D {}\n")
yield* Effect.promise(() =>
client.connection.sendRequest("test/configure-pull-diagnostics", {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registerOn: "didOpen",
registrations: [
{ identifier: "DocumentCompilerSemantic" },
@@ -370,40 +442,52 @@ describe("LSPClient interop", () => {
},
],
},
}),
)
})
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "full" }))
const version = await client.notify.open({ path: file })
await client.waitForDiagnostics({ path: file, version, mode: "full" })
expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file")
expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file")
}),
)
expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file")
expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file")
it.instance("full mode treats an empty workspace pull response as handled", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "client.cs")
yield* writeFile(file, "class C {}\n")
await client.shutdown()
},
})
})
const client = yield* createScopedClient(spawnFakeServer())
test("full mode treats an empty workspace pull response as handled", async () => {
const handle = spawnFakeServer() as any
await using tmp = await tmpdir()
const file = path.join(tmp.path, "client.cs")
await Bun.write(file, "class C {}\n")
yield* Effect.promise(() =>
client.connection.sendRequest("test/configure-pull-diagnostics", {
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const client = await LSPClient.create({
serverID: "fake",
server: handle as unknown as LSPServer.Handle,
root: tmp.path,
directory: tmp.path,
instance: ctx,
})
await client.connection.sendRequest("test/configure-pull-diagnostics", {
registerOn: "didOpen",
registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }],
workspaceDiagnosticsByIdentifier: {
WorkspaceDocumentsAndProject: [],
},
}),
)
})
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
const started = Date.now()
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "full" }))
const version = await client.notify.open({ path: file })
const started = Date.now()
await client.waitForDiagnostics({ path: file, version, mode: "full" })
expect(Date.now() - started).toBeLessThan(1_000)
}),
)
expect(Date.now() - started).toBeLessThan(1_000)
await client.shutdown()
},
})
})
})
+120 -106
View File
@@ -30,9 +30,16 @@ void Log.init({ print: false })
const encoder = new TextEncoder()
const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer)
const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer)
const it = testEffect(layer)
function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
return Effect.gen(function* () {
const svc = yield* Project.Service
return yield* fn(svc)
})
}
function remoteProjectID(remote: string) {
return ProjectID.make(Hash.fast(`git-remote:${remote}`))
}
@@ -96,18 +103,10 @@ function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.laye
}
const failureIt = (failArg: string) =>
testEffect(
Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer),
)
testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer))
const iconDiscoveryIt = testEffect(
Layer.mergeAll(
Layer.provideMerge(
projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }),
CrossSpawnSpawner.defaultLayer,
),
AppFileSystem.defaultLayer,
),
Layer.provideMerge(projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), CrossSpawnSpawner.defaultLayer),
)
function waitForProjectIcon(id: ProjectID, attempts = 50): Effect.Effect<Project.Info> {
@@ -126,7 +125,7 @@ describe("Project.fromDirectory", () => {
const tmp = yield* tmpdirScoped()
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project).toBeDefined()
expect(project.id).toBe(ProjectID.global)
@@ -134,7 +133,7 @@ describe("Project.fromDirectory", () => {
expect(project.worktree).toBe(tmp)
const opencodeFile = path.join(tmp, ".git", "opencode")
expect(yield* AppFileSystem.use.existsSafe(opencodeFile)).toBe(false)
expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(false)
}),
)
@@ -142,7 +141,7 @@ describe("Project.fromDirectory", () => {
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project).toBeDefined()
expect(project.id).not.toBe(ProjectID.global)
@@ -154,7 +153,7 @@ describe("Project.fromDirectory", () => {
it.live("returns global for non-git directory", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.id).toBe(ProjectID.global)
}),
)
@@ -162,8 +161,8 @@ describe("Project.fromDirectory", () => {
it.live("derives stable project ID from root commit", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project: a } = yield* Project.use.fromDirectory(tmp)
const { project: b } = yield* Project.use.fromDirectory(tmp)
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
const { project: b } = yield* run((svc) => svc.fromDirectory(tmp))
expect(b.id).toBe(a.id)
}),
)
@@ -173,7 +172,7 @@ describe("Project.fromDirectory", () => {
const tmp = yield* tmpdirScoped({ git: true })
yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet())
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
}),
@@ -186,8 +185,8 @@ describe("Project.fromDirectory", () => {
yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet())
yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet())
const { project: a } = yield* Project.use.fromDirectory(ssh)
const { project: b } = yield* Project.use.fromDirectory(https)
const { project: a } = yield* run((svc) => svc.fromDirectory(ssh))
const { project: b } = yield* run((svc) => svc.fromDirectory(https))
expect(a.id).toBe(remoteProjectID("github.com/owner/repo"))
expect(b.id).toBe(a.id)
@@ -197,7 +196,8 @@ describe("Project.fromDirectory", () => {
it.live("migrates cached root project data when origin becomes available", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project: rootProject } = yield* Project.use.fromDirectory(tmp)
const projects = yield* Project.Service
const { project: rootProject } = yield* projects.fromDirectory(tmp)
const remoteID = remoteProjectID("github.com/acme/app")
const sessionID = crypto.randomUUID() as SessionID
const workspaceID = WorkspaceID.ascending()
@@ -236,7 +236,7 @@ describe("Project.fromDirectory", () => {
})
yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet())
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* projects.fromDirectory(tmp)
expect(project.id).toBe(remoteID)
expect(
@@ -263,7 +263,7 @@ describe("Project.fromDirectory git failure paths", () => {
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
// rev-list fails because HEAD doesn't exist yet: this is the natural scenario.
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global)
expect(project.worktree).toBe(tmp)
@@ -274,7 +274,7 @@ describe("Project.fromDirectory git failure paths", () => {
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project, sandbox } = yield* Project.use.fromDirectory(tmp)
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(tmp)
}),
@@ -284,7 +284,7 @@ describe("Project.fromDirectory git failure paths", () => {
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project, sandbox } = yield* Project.use.fromDirectory(tmp)
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(tmp)
}),
@@ -296,7 +296,7 @@ describe("Project.fromDirectory with worktrees", () => {
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project, sandbox } = yield* Project.use.fromDirectory(tmp)
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.worktree).toBe(tmp)
expect(sandbox).toBe(tmp)
@@ -319,7 +319,7 @@ describe("Project.fromDirectory with worktrees", () => {
)
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet())
const { project, sandbox } = yield* Project.use.fromDirectory(worktreePath)
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(project.worktree).toBe(worktreePath)
expect(sandbox).toBe(worktreePath)
@@ -332,7 +332,7 @@ describe("Project.fromDirectory with worktrees", () => {
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project: main } = yield* Project.use.fromDirectory(tmp)
const { project: main } = yield* run((svc) => svc.fromDirectory(tmp))
const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared")
yield* Effect.addFinalizer(() =>
@@ -345,12 +345,12 @@ describe("Project.fromDirectory with worktrees", () => {
)
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet())
const { project: wt } = yield* Project.use.fromDirectory(worktreePath)
const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(wt.id).toBe(main.id)
const cache = path.join(tmp, ".git", "opencode")
const exists = yield* AppFileSystem.use.existsSafe(cache)
const exists = yield* Effect.promise(() => Bun.file(cache).exists())
expect(exists).toBe(true)
}),
)
@@ -368,8 +368,8 @@ describe("Project.fromDirectory with worktrees", () => {
yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet())
const { project: a } = yield* Project.use.fromDirectory(tmp)
const { project: b } = yield* Project.use.fromDirectory(clone)
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
const { project: b } = yield* run((svc) => svc.fromDirectory(clone))
expect(b.id).toBe(a.id)
}),
@@ -400,8 +400,8 @@ describe("Project.fromDirectory with worktrees", () => {
yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet())
yield* Effect.promise(() => $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp).quiet())
yield* Project.use.fromDirectory(worktree1)
const { project } = yield* Project.use.fromDirectory(worktree2)
yield* run((svc) => svc.fromDirectory(worktree1))
const { project } = yield* run((svc) => svc.fromDirectory(worktree2))
expect(project.worktree).toBe(worktree1)
expect(project.sandboxes).toContain(worktree2)
@@ -415,9 +415,9 @@ describe("Project.discover", () => {
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData)
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = yield* waitForProjectIcon(project.id)
expect(updated.icon?.url).toStartWith("data:")
@@ -428,12 +428,12 @@ describe("Project.discover", () => {
it.live("should discover favicon.png in root", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData)
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
yield* Project.use.discover(project)
yield* run((svc) => svc.discover(project))
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -447,11 +447,11 @@ describe("Project.discover", () => {
it.live("should not discover non-image files", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.txt"), "not an image")
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image"))
yield* Project.use.discover(project)
yield* run((svc) => svc.discover(project))
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -462,20 +462,22 @@ describe("Project.discover", () => {
it.live("should not discover favicon when override is set", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
yield* Project.use.update({
projectID: project.id,
icon: { override: "data:image/png;base64,override" },
})
yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { override: "data:image/png;base64,override" },
}),
)
const updatedProject = yield* Project.use.get(project.id)
const updatedProject = yield* run((svc) => svc.get(project.id))
if (!updatedProject) throw new Error("Project not found")
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData)
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
yield* Project.use.discover(updatedProject)
yield* run((svc) => svc.discover(updatedProject))
const updated = Project.get(project.id)
expect(updated).toBeDefined()
@@ -489,12 +491,14 @@ describe("Project.update", () => {
it.live("should update name", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = yield* Project.use.update({
projectID: project.id,
name: "New Project Name",
})
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
name: "New Project Name",
}),
)
expect(updated.name).toBe("New Project Name")
@@ -506,12 +510,14 @@ describe("Project.update", () => {
it.live("should update icon url", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = yield* Project.use.update({
projectID: project.id,
icon: { url: "https://example.com/icon.png" },
})
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { url: "https://example.com/icon.png" },
}),
)
expect(updated.icon?.url).toBe("https://example.com/icon.png")
@@ -523,12 +529,14 @@ describe("Project.update", () => {
it.live("should update icon color", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = yield* Project.use.update({
projectID: project.id,
icon: { color: "#ff0000" },
})
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { color: "#ff0000" },
}),
)
expect(updated.icon?.color).toBe("#ff0000")
@@ -540,12 +548,14 @@ describe("Project.update", () => {
it.live("should update icon override", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = yield* Project.use.update({
projectID: project.id,
icon: { override: "data:image/png;base64,abc123" },
})
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
icon: { override: "data:image/png;base64,abc123" },
}),
)
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
@@ -557,12 +567,14 @@ describe("Project.update", () => {
it.live("should update commands", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = yield* Project.use.update({
projectID: project.id,
commands: { start: "npm run dev" },
})
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
commands: { start: "npm run dev" },
}),
)
expect(updated.commands?.start).toBe("npm run dev")
@@ -573,12 +585,12 @@ describe("Project.update", () => {
it.live("should fail when project not found", () =>
Effect.gen(function* () {
const exit = yield* Project.use
.update({
const exit = yield* run((svc) =>
svc.update({
projectID: ProjectID.make("nonexistent-project-id"),
name: "Should Fail",
})
.pipe(Effect.exit)
}),
).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
@@ -590,7 +602,7 @@ describe("Project.update", () => {
it.live("should emit GlobalBus event on update", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
let eventPayload: any = null
const on = (data: any) => {
@@ -599,7 +611,7 @@ describe("Project.update", () => {
GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
yield* Project.use.update({ projectID: project.id, name: "Updated Name" })
yield* run((svc) => svc.update({ projectID: project.id, name: "Updated Name" }))
expect(eventPayload).not.toBeNull()
expect(eventPayload.payload.type).toBe("project.updated")
@@ -610,14 +622,16 @@ describe("Project.update", () => {
it.live("should update multiple fields at once", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const updated = yield* Project.use.update({
projectID: project.id,
name: "Multi Update",
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
commands: { start: "make start" },
})
const updated = yield* run((svc) =>
svc.update({
projectID: project.id,
name: "Multi Update",
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
commands: { start: "make start" },
}),
)
expect(updated.name).toBe("Multi Update")
expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
@@ -632,7 +646,7 @@ describe("Project.list and Project.get", () => {
it.live("list returns all projects", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const all = Project.list()
expect(all.length).toBeGreaterThan(0)
@@ -643,7 +657,7 @@ describe("Project.list and Project.get", () => {
it.live("get returns project by id", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const found = Project.get(project.id)
expect(found).toBeDefined()
@@ -661,7 +675,7 @@ describe("Project.setInitialized", () => {
it.live("sets time_initialized on project", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
expect(project.time.initialized).toBeUndefined()
@@ -677,15 +691,15 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
it.live("addSandbox adds directory and removeSandbox removes it", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const sandboxDir = path.join(tmp, "sandbox-test")
yield* Project.use.addSandbox(project.id, sandboxDir)
yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
let found = Project.get(project.id)
expect(found?.sandboxes).toContain(sandboxDir)
yield* Project.use.removeSandbox(project.id, sandboxDir)
yield* run((svc) => svc.removeSandbox(project.id, sandboxDir))
found = Project.get(project.id)
expect(found?.sandboxes).not.toContain(sandboxDir)
@@ -695,7 +709,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
it.live("addSandbox emits GlobalBus event", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const { project } = yield* Project.use.fromDirectory(tmp)
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
const sandboxDir = path.join(tmp, "sandbox-event")
const events: any[] = []
@@ -703,7 +717,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
yield* Project.use.addSandbox(project.id, sandboxDir)
yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
}),
@@ -725,7 +739,7 @@ describe("Project.fromDirectory with bare repos", () => {
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
const { project } = yield* Project.use.fromDirectory(worktreePath)
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(project.id).not.toBe(ProjectID.global)
expect(project.worktree).toBe(worktreePath)
@@ -733,8 +747,8 @@ describe("Project.fromDirectory with bare repos", () => {
const correctCache = path.join(barePath, "opencode")
const wrongCache = path.join(parentDir, ".git", "opencode")
expect(yield* AppFileSystem.use.existsSafe(correctCache)).toBe(true)
expect(yield* AppFileSystem.use.existsSafe(wrongCache)).toBe(false)
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)
expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false)
}),
)
@@ -759,8 +773,8 @@ describe("Project.fromDirectory with bare repos", () => {
yield* Effect.promise(() => $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet())
yield* Effect.promise(() => $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet())
const { project: projA } = yield* Project.use.fromDirectory(worktreeA)
const { project: projB } = yield* Project.use.fromDirectory(worktreeB)
const { project: projA } = yield* run((svc) => svc.fromDirectory(worktreeA))
const { project: projB } = yield* run((svc) => svc.fromDirectory(worktreeB))
expect(projA.id).not.toBe(projB.id)
@@ -768,9 +782,9 @@ describe("Project.fromDirectory with bare repos", () => {
const cacheB = path.join(bareB, "opencode")
const wrongCache = path.join(parentDir, ".git", "opencode")
expect(yield* AppFileSystem.use.existsSafe(cacheA)).toBe(true)
expect(yield* AppFileSystem.use.existsSafe(cacheB)).toBe(true)
expect(yield* AppFileSystem.use.existsSafe(wrongCache)).toBe(false)
expect(yield* Effect.promise(() => Bun.file(cacheA).exists())).toBe(true)
expect(yield* Effect.promise(() => Bun.file(cacheB).exists())).toBe(true)
expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false)
}),
)
@@ -788,13 +802,13 @@ describe("Project.fromDirectory with bare repos", () => {
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
const { project } = yield* Project.use.fromDirectory(worktreePath)
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
expect(project.id).not.toBe(ProjectID.global)
expect(project.worktree).toBe(worktreePath)
const correctCache = path.join(barePath, "opencode")
expect(yield* AppFileSystem.use.existsSafe(correctCache)).toBe(true)
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)
}),
)
})
@@ -271,6 +271,7 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
const model = createGpt5Model("gpt-5.2")
const result = ProviderTransform.options({ model, sessionID, providerOptions: {} })
expect(result.textVerbosity).toBe("low")
expect(result.include).toEqual(["reasoning.encrypted_content"])
})
test("gpt-5.1 should have textVerbosity set to low", () => {

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