diff --git a/bun.lock b/bun.lock index eeea1ce9..094ab342 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.65", + "@opencode-ai/sdk": "^1.2.5", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -95,7 +95,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.6.8", + "version": "1.6.9", "devDependencies": { "@tauri-apps/cli": "^2", "@types/node": "^24.3.1", @@ -104,7 +104,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.6.8", + "version": "1.6.9", "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", @@ -132,7 +132,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.1.65", + "@opencode-ai/sdk": "^1.2.5", "@pierre/diffs": "1.1.0-beta.13", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -145,6 +145,8 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", "@remixicon/react": "^4.7.0", + "@streamdown/code": "^1.0.2", + "@streamdown/mermaid": "^1.0.2", "@types/react-syntax-highlighter": "^15.5.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -162,7 +164,7 @@ "react-syntax-highlighter": "^15.6.6", "simple-git": "^3.28.0", "sonner": "^2.0.7", - "streamdown": "^1.6.10", + "streamdown": "^2.2.0", "strip-json-comments": "^5.0.3", "tailwind-merge": "^3.3.1", "yaml": "^2.8.1", @@ -198,10 +200,10 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.6.8", + "version": "1.6.9", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.1.65", + "@opencode-ai/sdk": "^1.2.5", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -221,7 +223,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.6.8", + "version": "1.6.9", "bin": { "openchamber": "./bin/cli.js", }, @@ -230,7 +232,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.65", + "@opencode-ai/sdk": "^1.2.5", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -843,7 +845,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.65", "", {}, "sha512-XlpgQJQ5WwO4tYgyyHoTT0NAB5/1StXonabVUAYVpW0JdtbwWFSdFEaLkWx6CU7MNW6ELP+SMC4n6wWO6zRW8Q=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.5", "", {}, "sha512-oFw08QNupBWuszpLa4HMRf/T+fDVqu8lbYSiqevdFTSH8Zih2cw/vPqLhjHgE6rPOSXyhvg46WivfSO7eNIpPw=="], "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="], @@ -1057,6 +1059,10 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="], + "@streamdown/code": ["@streamdown/code@1.0.2", "", { "dependencies": { "shiki": "^3.19.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-QKLS3sC8no5y0YvhGLA+ZjtNhznWU09IvFcjRKgSA35ulckMLw3b5T1ha+o1DaW8BS8l0zceLPFZa3/X9+agWQ=="], + + "@streamdown/mermaid": ["@streamdown/mermaid@1.0.2", "", { "dependencies": { "mermaid": "^11.12.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Fr/4sBWnAeSnxM3PcrV/+DiZe5oPMq9gOkUIAH7ZauJeuwrZ/DVzD4g0zlav6AH0axh2m/sOfrfLtY5aLT7niw=="], + "@surma/rollup-plugin-off-main-thread": ["@surma/rollup-plugin-off-main-thread@2.2.3", "", { "dependencies": { "ejs": "^3.1.6", "json5": "^2.2.0", "magic-string": "^0.25.0", "string.prototype.matchall": "^4.0.6" } }, "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ=="], "@swc/helpers": ["@swc/helpers@0.5.18", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="], @@ -1225,8 +1231,6 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], - "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], @@ -1973,18 +1977,8 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hast": ["hast@1.0.0", "", {}, "sha512-vFUqlRV5C+xqP76Wwq2SrM0kipnmpxJm7OfvVXpB35Fp+Fn4MV+ozr+JZr5qFvyR1q/U+Foim2x+3P+x9S1PLA=="], - - "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], - - "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], - - "hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="], - "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], - "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], - "hast-util-parse-selector": ["hast-util-parse-selector@2.2.5", "", {}, "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ=="], "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], @@ -1997,8 +1991,6 @@ "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], - "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], - "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], "hastscript": ["hastscript@6.0.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^1.0.0", "hast-util-parse-selector": "^2.0.0", "property-information": "^5.0.0", "space-separated-tokens": "^1.0.0" } }, "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w=="], @@ -2063,7 +2055,7 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], - "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], "intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="], @@ -2301,15 +2293,13 @@ "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], - "lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="], - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "marked": ["marked@17.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA=="], "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], @@ -2331,8 +2321,6 @@ "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], - "mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="], - "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], @@ -2361,12 +2349,6 @@ "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], - "micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@1.2.3", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-gRzVLUdjXBLX6zNPSnHGDoo+ZTp5zy+MZm0g3sv+3chPXY7l9gW+DnrcHcZh/jiPR6MjPKO4AEJNp4Aw6V9z5Q=="], - - "micromark-extension-cjk-friendly-gfm-strikethrough": ["micromark-extension-cjk-friendly-gfm-strikethrough@1.2.3", "", { "dependencies": { "devlop": "^1.1.0", "get-east-asian-width": "^1.3.0", "micromark-extension-cjk-friendly-util": "2.1.1", "micromark-util-character": "^2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-gSPnxgHDDqXYOBvQRq6lerrq9mjDhdtKn+7XETuXjxWcL62yZEfUdA28Ml1I2vDIPfAOIKLa0h2XDSGkInGHFQ=="], - - "micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@2.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" } }, "sha512-egs6+12JU2yutskHY55FyR48ZiEcFOJFyk9rsiyIhcJ6IvWB6ABBqVrBw8IobqJTDZ/wdSr9eoXDPb5S2nW1bg=="], - "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], @@ -2381,8 +2363,6 @@ "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], - "micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="], - "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], @@ -2687,27 +2667,19 @@ "rehype-harden": ["rehype-harden@1.1.7", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-j5DY0YSK2YavvNGV+qBHma15J9m0WZmRe8posT5AtKDS6TNWtMVTo6RiqF8SidfcASYz8f3k2J/1RWmq5zTXUw=="], - "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], - "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], "rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="], - "remark-cjk-friendly": ["remark-cjk-friendly@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g=="], - - "remark-cjk-friendly-gfm-strikethrough": ["remark-cjk-friendly-gfm-strikethrough@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly-gfm-strikethrough": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-bXfMZtsaomK6ysNN/UGRIcasQAYkC10NtPmP0oOHOV8YOhA2TXmwRXCku4qOzjIFxAPfish5+XS0eIug2PzNZA=="], - "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], - "remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="], - "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], - "remend": ["remend@1.0.1", "", {}, "sha512-152puVH0qMoRJQFnaMG+rVDdf01Jq/CaED+MBuXExurJgdbkLp0c3TIe4R12o28Klx8uyGsjvFNG05aFG69G9w=="], + "remend": ["remend@1.2.0", "", {}, "sha512-NbKrdWweTRuByPYErzQCNpNtsR9M1QQ0hK2UzmnmlSaEqHnkQ5Korlyi8KpdbOJ0rImJfRy4EAY0uDxYnL9Plw=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], @@ -2853,7 +2825,7 @@ "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], - "streamdown": ["streamdown@1.6.11", "", { "dependencies": { "clsx": "^2.1.1", "hast": "^1.0.0", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "katex": "^0.16.22", "lucide-react": "^0.542.0", "marked": "^16.2.1", "mermaid": "^11.11.0", "rehype-harden": "^1.1.6", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-cjk-friendly": "^1.2.3", "remark-cjk-friendly-gfm-strikethrough": "^1.2.3", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.0.1", "shiki": "^3.12.2", "tailwind-merge": "^3.3.1", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Y38fwRx5kCKTluwM+Gf27jbbi9q6Qy+WC9YrC1YbCpMkktT3PsRBJHMWiqYeF8y/JzLpB1IzDoeaB6qkQEDnAA=="], + "streamdown": ["streamdown@2.2.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "rehype-harden": "^1.1.7", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.2.0", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Y51o1I/sjpAy4Yn7j7R4TbUl9gcUZ7BTrHS+68IhrUBoYpNQZ28z06vww1MBFu4mSwvgF8xQIxIH2b9S9IHDyQ=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -3023,14 +2995,10 @@ "unique-string": ["unique-string@2.0.0", "", { "dependencies": { "crypto-random-string": "^2.0.0" } }, "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg=="], - "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], - "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], - "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], - "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], @@ -3383,8 +3351,6 @@ "globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], - "hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], - "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], "hastscript/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="], @@ -3415,6 +3381,8 @@ "mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -3541,8 +3509,6 @@ "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], - "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], - "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], "electron/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -3551,8 +3517,6 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "hast-util-from-dom/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], - "hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], "hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], diff --git a/package.json b/package.json index d2e3a014..a6e1bbee 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.65", + "@opencode-ai/sdk": "^1.2.5", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index d361738e..e0373266 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -117,6 +117,8 @@ const MENU_ITEM_REPORT_BUG_ID: &str = "menu_report_bug"; const MENU_ITEM_REQUEST_FEATURE_ID: &str = "menu_request_feature"; #[cfg(target_os = "macos")] const MENU_ITEM_JOIN_DISCORD_ID: &str = "menu_join_discord"; +#[cfg(target_os = "macos")] +const MENU_ITEM_CLEAR_CACHE_ID: &str = "menu_clear_cache"; #[cfg(target_os = "macos")] const GITHUB_BUG_REPORT_URL: &str = @@ -268,6 +270,9 @@ fn build_macos_menu( let join_discord = MenuItem::with_id(app, MENU_ITEM_JOIN_DISCORD_ID, "Join Discord", true, None::<&str>)?; + let clear_cache = + MenuItem::with_id(app, MENU_ITEM_CLEAR_CACHE_ID, "Clear Cache", true, None::<&str>)?; + let theme_submenu = Submenu::with_items(app, "Theme", true, &[&theme_light, &theme_dark, &theme_system])?; @@ -293,6 +298,8 @@ fn build_macos_menu( &help_dialog, &download_logs, &PredefinedMenuItem::separator(app)?, + &clear_cache, + &PredefinedMenuItem::separator(app)?, &report_bug, &request_feature, &PredefinedMenuItem::separator(app)?, @@ -412,6 +419,35 @@ fn desktop_set_auto_worktree_menu(app: tauri::AppHandle, enabled: bool) -> Resul Ok(()) } +#[tauri::command] +fn desktop_clear_cache(app: tauri::AppHandle) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let mut failures: Vec = Vec::new(); + + for (label, window) in app.webview_windows() { + if let Err(err) = window.clear_all_browsing_data() { + failures.push(format!("{label}: {err}")); + } + } + + if !failures.is_empty() { + return Err(format!("Failed to clear browsing data for some windows: {}", failures.join("; "))); + } + + // Reload all windows after clearing persisted browsing data so in-memory state is reset too. + eval_in_all_windows(&app, "window.location.reload();"); + + log::info!("[desktop] Cleared all webview browsing data and reloaded windows"); + return Ok(()); + } + + #[cfg(not(target_os = "macos"))] + { + Err("desktop_clear_cache is only supported on macOS".to_string()) + } +} + #[tauri::command] fn desktop_open_path(path: String, app: Option) -> Result<(), String> { let trimmed = path.trim(); @@ -2455,6 +2491,14 @@ fn main() { } if id == MENU_ITEM_DOWNLOAD_LOGS_ID { dispatch_menu_action(app, "download-logs"); + return; + } + if id == MENU_ITEM_CLEAR_CACHE_ID { + let app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + let _ = crate::desktop_clear_cache(app); + }); + return; } } }) @@ -2498,6 +2542,7 @@ fn main() { desktop_new_window, desktop_new_window_at_url, desktop_set_auto_worktree_menu, + desktop_clear_cache, desktop_open_path, desktop_filter_installed_apps, desktop_get_installed_apps, diff --git a/packages/ui/package.json b/packages/ui/package.json index 9a5d5a42..62129851 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -37,7 +37,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.1.65", + "@opencode-ai/sdk": "^1.2.5", "@pierre/diffs": "1.1.0-beta.13", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -50,6 +50,8 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", "@remixicon/react": "^4.7.0", + "@streamdown/code": "^1.0.2", + "@streamdown/mermaid": "^1.0.2", "@types/react-syntax-highlighter": "^15.5.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -67,7 +69,7 @@ "react-syntax-highlighter": "^15.6.6", "simple-git": "^3.28.0", "sonner": "^2.0.7", - "streamdown": "^1.6.10", + "streamdown": "^2.2.0", "strip-json-comments": "^5.0.3", "tailwind-merge": "^3.3.1", "yaml": "^2.8.1", diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index fbe685ee..335b7a49 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -341,10 +341,10 @@ export const ChatContainer: React.FC = () => { if (!shouldSkipScroll) { if (typeof window === 'undefined') { - scrollToBottom(); + scrollToBottom({ instant: true }); } else { window.requestAnimationFrame(() => { - scrollToBottom(); + scrollToBottom({ instant: true }); }); } } @@ -372,7 +372,7 @@ export const ChatContainer: React.FC = () => { style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined} >
- +
diff --git a/packages/ui/src/components/chat/ChatEmptyState.tsx b/packages/ui/src/components/chat/ChatEmptyState.tsx index 4fca9297..0dbd93f9 100644 --- a/packages/ui/src/components/chat/ChatEmptyState.tsx +++ b/packages/ui/src/components/chat/ChatEmptyState.tsx @@ -1,8 +1,12 @@ import React from 'react'; +import { RiGitBranchLine } from '@remixicon/react'; import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { TextLoop } from '@/components/ui/TextLoop'; import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useGitStatus, useGitStore } from '@/stores/useGitStore'; const phrases = [ "Fix the failing tests", @@ -23,15 +27,51 @@ const phrases = [ "Add type definitions", ]; -const ChatEmptyState: React.FC = () => { +interface ChatEmptyStateProps { + showDraftContext?: boolean; +} + +const ChatEmptyState: React.FC = ({ + showDraftContext = false, +}) => { const { currentTheme } = useThemeSystem(); + const { git } = useRuntimeAPIs(); + const effectiveDirectory = useEffectiveDirectory(); + const { setActiveDirectory, fetchStatus } = useGitStore(); + const gitStatus = useGitStatus(effectiveDirectory ?? null); // Use theme's muted foreground for secondary text const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)'; + const branchName = typeof gitStatus?.current === 'string' && gitStatus.current.trim().length > 0 + ? gitStatus.current.trim() + : null; + + React.useEffect(() => { + if (!showDraftContext || !effectiveDirectory) { + return; + } + + setActiveDirectory(effectiveDirectory); + + const state = useGitStore.getState().directories.get(effectiveDirectory); + if (!state?.status && state?.isGitRepo !== false) { + void fetchStatus(effectiveDirectory, git, { silent: true }); + } + }, [effectiveDirectory, fetchStatus, git, setActiveDirectory, showDraftContext]); return (
+ {showDraftContext && ( +
+ {branchName && ( +
+ + {branchName} +
+ )} +
+ )} { + const themeSystem = useOptionalThemeSystem(); + const fallbackLight = getDefaultTheme(false); + const fallbackDark = getDefaultTheme(true); + + const currentTheme = themeSystem?.currentTheme + ?? (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches + ? fallbackDark + : fallbackLight); + + const mermaidRenderKey = `${currentTheme.metadata.id}:${currentTheme.metadata.variant}:${themeSystem?.themeMode ?? 'fallback'}`; + + const options = React.useMemo(() => { + const isDark = currentTheme.metadata.variant === 'dark'; + return { + config: { + theme: isDark ? 'dark' : 'base', + themeVariables: { + primaryColor: currentTheme.colors.surface.elevated, + primaryTextColor: currentTheme.colors.surface.foreground, + primaryBorderColor: currentTheme.colors.interactive.border, + lineColor: currentTheme.colors.interactive.border, + secondaryColor: currentTheme.colors.surface.muted, + tertiaryColor: currentTheme.colors.surface.subtle, + background: currentTheme.colors.surface.background, + mainBkg: currentTheme.colors.surface.elevated, + nodeTextColor: currentTheme.colors.surface.foreground, + edgeLabelBackground: currentTheme.colors.surface.background, + }, + }, + }; + }, [currentTheme]); + + return React.useMemo( + () => ({ options, mermaidRenderKey }), + [mermaidRenderKey, options], + ); +}; + // Table utility functions const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => { const headers: string[] = []; @@ -280,21 +323,22 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject document.removeEventListener('mousedown', handleClickOutside); }, []); - const handleDownload = (format: 'csv' | 'markdown') => { - const tableEl = tableRef.current?.querySelector('table'); - if (!tableEl) return; - - try { - const data = extractTableData(tableEl); - const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data); - const filename = format === 'csv' ? 'table.csv' : 'table.md'; - const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown'; - downloadFile(filename, content, mimeType); - setShowMenu(false); - } catch (err) { - console.error('Failed to download table:', err); - } - }; + const handleDownload = (format: 'csv' | 'markdown') => { + const tableEl = tableRef.current?.querySelector('table'); + if (!tableEl) return; + + try { + const data = extractTableData(tableEl); + const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data); + const filename = format === 'csv' ? 'table.csv' : 'table.md'; + const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown'; + downloadFile(filename, content, mimeType); + setShowMenu(false); + toast.success(`Table downloaded as ${format.toUpperCase()}`); + } catch (err) { + console.error('Failed to download table:', err); + } + }; return (
@@ -348,9 +392,35 @@ type CodeBlockWrapperProps = React.HTMLAttributes & { children?: React.ReactNode; }; +const getMermaidInfo = (children: React.ReactNode): { isMermaid: boolean; source: string } => { + if (!React.isValidElement(children)) return { isMermaid: false, source: '' }; + const props = children.props as Record | undefined; + const className = typeof props?.className === 'string' ? props.className : ''; + if (!className.includes('language-mermaid')) return { isMermaid: false, source: '' }; + // Extract raw mermaid source from the code element's children + const codeChildren = props?.children; + let source = ''; + if (typeof codeChildren === 'string') { + source = codeChildren; + } else if (React.isValidElement(codeChildren)) { + const innerProps = codeChildren.props as Record | undefined; + if (typeof innerProps?.children === 'string') source = innerProps.children; + } + return { isMermaid: true, source }; +}; + const CodeBlockWrapper: React.FC = ({ children, className, style, ...props }) => { const [copied, setCopied] = React.useState(false); const codeRef = React.useRef(null); + const mermaidInfo = getMermaidInfo(children); + const codeChild = React.useMemo( + () => ( + React.isValidElement(children) + ? React.cloneElement(children as React.ReactElement>, { 'data-block': true }) + : children + ), + [children], + ); const normalizedStyle = React.useMemo(() => { if (!style) return style; @@ -396,6 +466,11 @@ const CodeBlockWrapper: React.FC = ({ children, className return next; }, [style]); + // Mermaid blocks get their own controls via MermaidWrapper — skip the code copy button. + if (mermaidInfo.isMermaid) { + return {codeChild}; + } + const getCodeContent = (): string => { if (!codeRef.current) return ''; const codeEl = codeRef.current.querySelector('code'); @@ -422,7 +497,7 @@ const CodeBlockWrapper: React.FC = ({ children, className className={cn(className)} style={normalizedStyle} > - {children} + {codeChild}
+ ); +}; + +// Mermaid download button — downloads rendered SVG +const MermaidDownloadButton: React.FC<{ containerRef: React.RefObject }> = ({ containerRef }) => { + const handleDownload = () => { + const svgEl = containerRef.current?.querySelector('svg'); + if (!(svgEl instanceof SVGElement)) return; + const serializer = new XMLSerializer(); + let markup = serializer.serializeToString(svgEl); + if (!markup.includes('xmlns=')) { + markup = markup.replace(' + + + ); +}; + +// Mermaid wrapper with custom controls (same pattern as TableWrapper) +const MermaidWrapper: React.FC<{ children: React.ReactNode; source: string }> = ({ children, source }) => { + const containerRef = React.useRef(null); + + return ( +
+ {children} +
+ + +
+
+ ); +}; + export type MarkdownVariant = 'assistant' | 'tool'; interface MarkdownRendererProps { @@ -464,6 +621,7 @@ export const MarkdownRenderer: React.FC = ({ variant = 'assistant', }) => { const shikiThemes = useMarkdownShikiThemes(); + const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions(); const componentKey = React.useMemo(() => { const signature = part?.id ? `part-${part.id}` : `message-${messageId}`; return `markdown-${signature}`; @@ -476,12 +634,15 @@ export const MarkdownRenderer: React.FC = ({ const markdownContent = (
+ key={`streamdown-${componentKey}-${mermaidRenderKey}`} + mode={isStreaming ? 'streaming' : 'static'} + shikiTheme={shikiThemes} + className={streamdownClassName} + controls={streamdownControls} + plugins={streamdownPlugins} + mermaid={mermaidOptions} + components={streamdownComponents} + > {content}
@@ -504,6 +665,7 @@ export const SimpleMarkdownRenderer: React.FC<{ variant?: MarkdownVariant; }> = ({ content, className, variant = 'assistant' }) => { const shikiThemes = useMarkdownShikiThemes(); + const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions(); const streamdownClassName = variant === 'tool' ? 'streamdown-content streamdown-tool' @@ -512,10 +674,13 @@ export const SimpleMarkdownRenderer: React.FC<{ return (
{content} diff --git a/packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx b/packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx index deb4170f..d9ad06d9 100644 --- a/packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx +++ b/packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx @@ -243,19 +243,25 @@ const isActivityStandaloneTool = (toolName: unknown): boolean => { }; const extractFinalAssistantText = (turn: Turn): string | undefined => { - for (const assistantMsg of turn.assistantMessages) { + for (let messageIndex = turn.assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) { + const assistantMsg = turn.assistantMessages[messageIndex]; + if (!assistantMsg) continue; + const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish; - if (infoFinish === 'stop') { - const textPart = assistantMsg.parts.find(p => p.type === 'text'); - if (textPart) { - const textContent = (textPart as { text?: string | null | undefined }).text ?? - (textPart as { content?: string | null | undefined }).content; - if (typeof textContent === 'string' && textContent.trim().length > 0) { - return textContent; - } + if (infoFinish !== 'stop') continue; + + for (let partIndex = assistantMsg.parts.length - 1; partIndex >= 0; partIndex -= 1) { + const part = assistantMsg.parts[partIndex]; + if (!part || part.type !== 'text') continue; + + const textContent = (part as { text?: string | null | undefined }).text ?? + (part as { content?: string | null | undefined }).content; + if (typeof textContent === 'string' && textContent.trim().length > 0) { + return textContent; } } } + return undefined; }; diff --git a/packages/ui/src/components/chat/hooks/useTurnGrouping.ts b/packages/ui/src/components/chat/hooks/useTurnGrouping.ts index 11b65f4e..9c568899 100644 --- a/packages/ui/src/components/chat/hooks/useTurnGrouping.ts +++ b/packages/ui/src/components/chat/hooks/useTurnGrouping.ts @@ -102,21 +102,25 @@ export const detectTurns = (messages: ChatMessageEntry[]): Turn[] => { }; const extractFinalAssistantText = (turn: Turn): string | undefined => { + for (let messageIndex = turn.assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) { + const assistantMsg = turn.assistantMessages[messageIndex]; + if (!assistantMsg) continue; - for (const assistantMsg of turn.assistantMessages) { const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish; + if (infoFinish !== 'stop') continue; - if (infoFinish === 'stop') { - const textPart = assistantMsg.parts.find(p => p.type === 'text'); - if (textPart) { - const textContent = (textPart as { text?: string | null | undefined }).text ?? - (textPart as { content?: string | null | undefined }).content; - if (typeof textContent === 'string' && textContent.trim().length > 0) { - return textContent; - } + for (let partIndex = assistantMsg.parts.length - 1; partIndex >= 0; partIndex -= 1) { + const part = assistantMsg.parts[partIndex]; + if (!part || part.type !== 'text') continue; + + const textContent = (part as { text?: string | null | undefined }).text ?? + (part as { content?: string | null | undefined }).content; + if (typeof textContent === 'string' && textContent.trim().length > 0) { + return textContent; } } } + return undefined; }; diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 193658a4..afdd7c87 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -329,7 +329,7 @@ const AssistantMessageBody: React.FC> = ({ const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt); const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false; const hasStopFinish = messageFinish === 'stop'; - + // TTS for message playback const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS(); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); diff --git a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx index 4d4bf782..ebc392a4 100644 --- a/packages/ui/src/components/chat/message/ToolOutputDialog.tsx +++ b/packages/ui/src/components/chat/message/ToolOutputDialog.tsx @@ -16,6 +16,7 @@ import { renderWebSearchOutput, formatInputForDisplay, parseDiffToUnified, + parseReadToolOutput, type UnifiedDiffHunk, type SideBySideDiffHunk, type SideBySideDiffLine, @@ -740,33 +741,41 @@ const ToolOutputDialog: React.FC = ({ popup, onOpenChange } if (tool === 'read') { - const lines = popup.content.split('\n'); + const parsedReadOutput = parseReadToolOutput(popup.content); const inputMeta = popup.metadata?.input; const inputObj = typeof inputMeta === 'object' && inputMeta !== null ? (inputMeta as Record) : {}; const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0; - const limit = typeof inputObj.limit === 'number' ? inputObj.limit : undefined; - - const isInfoMessage = (line: string) => line.trim().startsWith('('); + let fallbackLineCursor = offset; + const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null); return (
- {lines.map((line: string, idx: number) => { + {parsedReadOutput.lines.map((line, idx: number) => { + if (line.lineNumber !== null) { + fallbackLineCursor = line.lineNumber; + } - const isInfo = isInfoMessage(line); + const shouldAssignFallbackLineNumber = + parsedReadOutput.type === 'file' + && !hasExplicitLineNumbers + && line.lineNumber === null + && !line.isInfo; - const lineNumber = offset + idx + 1; + const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallbackLineNumber + ? (fallbackLineCursor += 1) + : null); - const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit); + const shouldShowLineNumber = !line.isInfo && effectiveLineNumber !== null; return ( -
+
- {shouldShowLineNumber ? lineNumber : ''} + {shouldShowLineNumber ? effectiveLineNumber : ''}
- {isInfo ? ( -
{line}
+ {line.isInfo ? ( +
{line.text}
) : ( = ({ popup, onOpenChange }, }} > - {line} + {line.text} )}
diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 186e033e..20b16f72 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -24,6 +24,7 @@ import { formatEditOutput, detectLanguageFromOutput, formatInputForDisplay, + parseReadToolOutput, } from '../toolRenderers'; type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record; input?: Record; output?: string; error?: string; time?: { start: number; end?: number } }; @@ -808,31 +809,40 @@ const ToolExpandedContent: React.FC = React.memo(({ if (hasStringOutput && outputString.trim()) { if (part.tool === 'read') { - const formattedOutput = formatEditOutput(outputString, part.tool, metadata); - const lines = formattedOutput.split('\n'); + const parsedReadOutput = parseReadToolOutput(outputString); const offset = typeof input?.offset === 'number' ? input.offset : 0; - const limit = typeof input?.limit === 'number' ? input.limit : undefined; - const isInfoMessage = (line: string) => line.trim().startsWith('('); + const contentForLanguage = parsedReadOutput.lines.map((line) => line.text).join('\n'); + let fallbackLineCursor = offset; + const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null); return renderScrollableBlock(
- {lines.map((line: string, idx: number) => { - const isInfo = isInfoMessage(line); - const lineNumber = offset + idx + 1; - const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit); + {parsedReadOutput.lines.map((line, idx) => { + if (line.lineNumber !== null) { + fallbackLineCursor = line.lineNumber; + } + const shouldAssignFallbackLineNumber = + parsedReadOutput.type === 'file' + && !hasExplicitLineNumbers + && line.lineNumber === null + && !line.isInfo; + const effectiveLineNumber = line.lineNumber ?? (shouldAssignFallbackLineNumber + ? (fallbackLineCursor += 1) + : null); + const shouldShowLineNumber = !line.isInfo && effectiveLineNumber !== null; return ( -
+
- {shouldShowLineNumber ? lineNumber : ''} + {shouldShowLineNumber ? effectiveLineNumber : ''}
- {isInfo ? ( -
{line}
+ {line.isInfo ? ( +
{line.text}
) : ( )} + language={detectLanguageFromOutput(contentForLanguage, part.tool, input as Record)} PreTag="div" wrapLines wrapLongLines @@ -855,7 +865,7 @@ const ToolExpandedContent: React.FC = React.memo(({ }, }} > - {line} + {line.text} )}
@@ -1073,6 +1083,7 @@ const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxT const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity); const justificationText = React.useMemo(() => { if (!showTextJustificationActivity) return null; + if (part.tool === 'apply_patch') return null; // Get title or description from state - this is the "yapping" text like "Shows system information" const title = (stateWithData as { title?: string }).title; if (typeof title === 'string' && title.trim().length > 0) { @@ -1083,7 +1094,7 @@ const ToolPart: React.FC = ({ part, isExpanded, onToggle, syntaxT return inputDesc; } return null; - }, [showTextJustificationActivity, stateWithData, input]); + }, [showTextJustificationActivity, part.tool, stateWithData, input]); const runtime = React.useContext(RuntimeAPIContext); diff --git a/packages/ui/src/components/chat/message/toolRenderers.tsx b/packages/ui/src/components/chat/message/toolRenderers.tsx index 543d4f3b..ed6fc995 100644 --- a/packages/ui/src/components/chat/message/toolRenderers.tsx +++ b/packages/ui/src/components/chat/message/toolRenderers.tsx @@ -44,6 +44,81 @@ export const formatEditOutput = (output: string, toolName: string, metadata?: Re return cleaned; }; +export interface ParsedReadOutputLine { + text: string; + lineNumber: number | null; + isInfo: boolean; +} + +export interface ParsedReadToolOutput { + type: 'file' | 'directory' | 'unknown'; + lines: ParsedReadOutputLine[]; +} + +export const parseReadToolOutput = (output: string): ParsedReadToolOutput => { + const typeMatch = output.match(/(file|directory)<\/type>/i); + const detectedType = (typeMatch?.[1]?.toLowerCase() ?? 'unknown') as ParsedReadToolOutput['type']; + + const contentMatch = output.match(/([\s\S]*?)<\/content>/i); + const rawContent = contentMatch?.[1] ?? output; + const normalizedContent = rawContent.replace(/\r\n/g, '\n'); + const rawLines = normalizedContent.split('\n'); + + const isTruncationInfoLine = (text: string): boolean => { + return /\(\s*File has more lines\..*offset.*\)/i.test(text.trim()); + }; + + const parsedLines = rawLines.map((line): ParsedReadOutputLine => { + const trimmed = line.trim(); + const isInfo = (trimmed.startsWith('(') && trimmed.endsWith(')')) || isTruncationInfoLine(trimmed); + + if (detectedType !== 'directory') { + const numberedMatch = line.match(/^(\d+):\s?(.*)$/); + if (numberedMatch) { + const numberedText = numberedMatch[2]; + const numberedTrimmed = numberedText.trim(); + const numberedIsInfo = + (numberedTrimmed.startsWith('(') && numberedTrimmed.endsWith(')')) + || isTruncationInfoLine(numberedTrimmed); + return { + lineNumber: numberedIsInfo ? null : Number(numberedMatch[1]), + text: numberedText, + isInfo: numberedIsInfo, + }; + } + } + + return { + lineNumber: null, + text: line, + isInfo, + }; + }); + + const lines = parsedLines.filter((line, index, arr) => { + if (line.text.trim().length > 0) { + return true; + } + + const prev = arr[index - 1]; + const next = arr[index + 1]; + const adjacentToInfo = Boolean(prev?.isInfo || next?.isInfo); + const hasNumber = line.lineNumber !== null; + + // Drop numbered blank lines wrapped around helper/info rows. + if (adjacentToInfo && hasNumber) { + return false; + } + + return true; + }); + + return { + type: detectedType, + lines, + }; +}; + export const renderListOutput = (output: string, options?: { unstyled?: boolean }) => { try { const lines = output.trim().split('\n').filter(Boolean); diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 9b56594b..8df72d98 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -7,6 +7,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { cn } from '@/lib/utils'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useUIStore } from '@/stores/useUIStore'; +import { ContextPanelContent } from './ContextSidebarTab'; const CONTEXT_PANEL_MIN_WIDTH = 360; const CONTEXT_PANEL_MAX_WIDTH = 1400; @@ -144,14 +145,16 @@ export const ContextPanel: React.FC = () => { const activeFilePath = useFilesViewTabsStore((state) => (directoryKey ? (state.byRoot[directoryKey]?.selectedPath ?? null) : null)); - const panelTitle = panelState?.mode === 'diff' ? 'Diff' : panelState?.mode === 'file' ? 'File' : 'Panel'; - const effectivePath = panelState?.mode === 'file' ? (activeFilePath ?? panelState?.targetPath ?? null) : (panelState?.targetPath ?? null); + const panelTitle = panelState?.mode === 'diff' ? 'Diff' : panelState?.mode === 'file' ? 'File' : panelState?.mode === 'context' ? 'Context' : 'Panel'; + const effectivePath = panelState?.mode === 'file' ? (activeFilePath ?? panelState?.targetPath ?? null) : panelState?.mode === 'context' ? null : (panelState?.targetPath ?? null); const pathLabel = getRelativePathLabel(effectivePath, effectiveDirectory); const content = panelState?.mode === 'diff' ? : panelState?.mode === 'file' ? + : panelState?.mode === 'context' + ? : null; const header = ( diff --git a/packages/ui/src/components/layout/ContextSidebarTab.tsx b/packages/ui/src/components/layout/ContextSidebarTab.tsx new file mode 100644 index 00000000..963c519d --- /dev/null +++ b/packages/ui/src/components/layout/ContextSidebarTab.tsx @@ -0,0 +1,606 @@ +import React from 'react'; +import type { Message, Part } from '@opencode-ai/sdk/v2'; +import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; + +import { deriveMessageRole } from '@/components/chat/message/messageRole'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useSessionStore } from '@/stores/useSessionStore'; + +type SessionMessage = { info: Message; parts: Part[] }; + +const EMPTY_SESSION_MESSAGES: SessionMessage[] = []; + +type ProviderModelLike = { + id?: string; + name?: string; + limit?: { context?: number }; +}; + +type ProviderLike = { + id?: string; + name?: string; + models?: ProviderModelLike[]; +}; + +type TokenBreakdown = { + input: number; + output: number; + reasoning: number; + cacheRead: number; + cacheWrite: number; + total: number; +}; + +type ContextBuckets = { + user: number; + assistant: number; + tool: number; + other: number; +}; + +const EMPTY_BREAKDOWN: TokenBreakdown = { + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, +}; + +const EMPTY_BUCKETS: ContextBuckets = { + user: 0, + assistant: 0, + tool: 0, + other: 0, +}; + +const toNonNegativeNumber = (value: unknown): number => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + return 0; + } + return value; +}; + +const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => { + const tokenCandidate = (message.info as { tokens?: unknown }).tokens; + const source = + tokenCandidate !== undefined + ? tokenCandidate + : (message.parts.find((part) => (part as { tokens?: unknown }).tokens !== undefined) as { tokens?: unknown } | undefined)?.tokens; + + if (typeof source === 'number') { + return { + ...EMPTY_BREAKDOWN, + total: toNonNegativeNumber(source), + }; + } + + if (!source || typeof source !== 'object') { + return EMPTY_BREAKDOWN; + } + + const breakdown = source as { + input?: unknown; + output?: unknown; + reasoning?: unknown; + cache?: { read?: unknown; write?: unknown }; + }; + + const input = toNonNegativeNumber(breakdown.input); + const output = toNonNegativeNumber(breakdown.output); + const reasoning = toNonNegativeNumber(breakdown.reasoning); + const cacheRead = toNonNegativeNumber(breakdown.cache?.read); + const cacheWrite = toNonNegativeNumber(breakdown.cache?.write); + + return { + input, + output, + reasoning, + cacheRead, + cacheWrite, + total: input + output + reasoning + cacheRead + cacheWrite, + }; +}; + +const pickString = (...values: unknown[]): string => { + for (const value of values) { + if (typeof value === 'string' && value.trim().length > 0) { + return value; + } + } + return ''; +}; + +const estimateTextLength = (value: unknown): number => { + if (typeof value === 'string') { + return value.length; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value).length; + } + if (Array.isArray(value)) { + return value.reduce((sum, item) => sum + estimateTextLength(item), 0); + } + if (value && typeof value === 'object') { + return Object.values(value as Record).reduce((sum, item) => sum + estimateTextLength(item), 0); + } + return 0; +}; + +const estimatePartChars = (part: Part, role: 'user' | 'assistant' | 'tool' | 'other'): ContextBuckets => { + const partRecord = part as Record; + const type = typeof partRecord.type === 'string' ? partRecord.type : ''; + + if (type === 'reasoning') { + return { + ...EMPTY_BUCKETS, + assistant: estimateTextLength(partRecord.text) + estimateTextLength(partRecord.content), + }; + } + + const directText = pickString( + partRecord.text, + partRecord.content, + partRecord.value, + (partRecord.source as { value?: unknown; text?: { value?: unknown } } | undefined)?.value, + (partRecord.source as { value?: unknown; text?: { value?: unknown } } | undefined)?.text?.value, + ); + + if (type === 'tool' || role === 'tool') { + const toolInputOutputLength = + estimateTextLength(partRecord.input) + + estimateTextLength(partRecord.output) + + estimateTextLength(partRecord.error) + + estimateTextLength((partRecord.call as { input?: unknown; output?: unknown; error?: unknown } | undefined)?.input) + + estimateTextLength((partRecord.call as { input?: unknown; output?: unknown; error?: unknown } | undefined)?.output) + + estimateTextLength((partRecord.call as { input?: unknown; output?: unknown; error?: unknown } | undefined)?.error); + + const toolPayloadLength = + toolInputOutputLength + + estimateTextLength(partRecord.raw) + + Math.round(estimateTextLength(partRecord.metadata) * 0.25) + + Math.round(estimateTextLength(partRecord.state) * 0.1); + + return { user: 0, assistant: 0, tool: toolPayloadLength, other: 0 }; + } + + if (role === 'user') { + return { user: directText.length, assistant: 0, tool: 0, other: 0 }; + } + + if (role === 'assistant') { + return { user: 0, assistant: directText.length, tool: 0, other: 0 }; + } + + return { user: 0, assistant: 0, tool: 0, other: directText.length }; +}; + +const addBuckets = (target: ContextBuckets, value: ContextBuckets): ContextBuckets => ({ + user: target.user + value.user, + assistant: target.assistant + value.assistant, + tool: target.tool + value.tool, + other: target.other + value.other, +}); + +const deriveRoleBucket = (message: SessionMessage): 'user' | 'assistant' | 'tool' | 'other' => { + const roleInfo = deriveMessageRole(message.info); + if (roleInfo.isUser) return 'user'; + if (roleInfo.role === 'assistant') return 'assistant'; + if (roleInfo.role === 'tool') return 'tool'; + return 'other'; +}; + +const computeContextBreakdown = ( + sessionMessages: SessionMessage[], + systemPrompt: string, +): ContextBuckets => { + if (sessionMessages.length === 0) { + return { ...EMPTY_BUCKETS }; + } + + const totalChars = sessionMessages.reduce((acc, message) => { + const role = deriveRoleBucket(message); + let bucket = { ...EMPTY_BUCKETS }; + for (const part of message.parts) { + bucket = addBuckets(bucket, estimatePartChars(part, role)); + } + return addBuckets(acc, bucket); + }, { ...EMPTY_BUCKETS }); + + totalChars.user += systemPrompt.length; + + return { + user: Math.ceil(totalChars.user / 4), + assistant: Math.ceil(totalChars.assistant / 4), + tool: Math.ceil(totalChars.tool / 4), + other: Math.ceil(totalChars.other / 4), + }; +}; + +const formatNumber = (value: number): string => value.toLocaleString(); + +const formatMoney = (value: number): string => { + if (!Number.isFinite(value) || value <= 0) return '$0.00'; + if (value < 0.01) return `$${value.toFixed(4)}`; + return `$${value.toFixed(2)}`; +}; + +const formatDateTime = (timestamp: number | null): string => { + if (!timestamp || !Number.isFinite(timestamp)) return '-'; + const value = new Date(timestamp).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + return value.replace(/, (\d{1,2}:\d{2} [AP]M)$/, ' at $1'); +}; + +const formatMessageDateMeta = (timestamp: number | null): string => { + if (!timestamp || !Number.isFinite(timestamp)) return '-'; + return new Date(timestamp).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +}; + +const capitalizeRole = (role: string): string => { + if (!role) return role; + return `${role[0].toUpperCase()}${role.slice(1)}`; +}; + +const resolveProviderAndModel = ( + providers: ProviderLike[], + providerID: string, + modelID: string, +): { providerName: string; modelName: string; contextLimit: number | null } => { + const provider = providers.find((entry) => entry.id === providerID); + const model = provider?.models?.find((entry) => entry.id === modelID); + + return { + providerName: provider?.name || providerID || '-', + modelName: model?.name || modelID || '-', + contextLimit: typeof model?.limit?.context === 'number' ? model.limit.context : null, + }; +}; + +export const ContextPanelContent: React.FC = () => { + const { currentTheme } = useThemeSystem(); + const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]); + const [expandedRawMessages, setExpandedRawMessages] = React.useState>({}); + const [copiedRawMessageId, setCopiedRawMessageId] = React.useState(null); + const copyResetTimeoutRef = React.useRef(null); + const currentSessionId = useSessionStore((state) => state.currentSessionId); + const sessions = useSessionStore((state) => state.sessions); + const sessionMessages = useSessionStore((state) => { + if (!state.currentSessionId) return EMPTY_SESSION_MESSAGES; + return state.messages.get(state.currentSessionId) ?? EMPTY_SESSION_MESSAGES; + }); + const providers = useConfigStore((state) => state.providers); + + React.useEffect(() => { + if (copyResetTimeoutRef.current !== null) { + window.clearTimeout(copyResetTimeoutRef.current); + copyResetTimeoutRef.current = null; + } + setExpandedRawMessages((prev) => (Object.keys(prev).length > 0 ? {} : prev)); + setCopiedRawMessageId(null); + }, [currentSessionId]); + + React.useEffect(() => { + return () => { + if (copyResetTimeoutRef.current !== null) { + window.clearTimeout(copyResetTimeoutRef.current); + copyResetTimeoutRef.current = null; + } + }; + }, []); + + const handleCopyRawMessage = React.useCallback(async (messageId: string, value: string) => { + try { + await navigator.clipboard.writeText(value); + setCopiedRawMessageId(messageId); + if (copyResetTimeoutRef.current !== null) { + window.clearTimeout(copyResetTimeoutRef.current); + } + copyResetTimeoutRef.current = window.setTimeout(() => { + setCopiedRawMessageId((prev) => (prev === messageId ? null : prev)); + copyResetTimeoutRef.current = null; + }, 2000); + } catch { + setCopiedRawMessageId(null); + } + }, []); + + const viewModel = React.useMemo(() => { + const currentSession = currentSessionId ? sessions.find((session) => session.id === currentSessionId) ?? null : null; + + const assistantMessages = sessionMessages.filter((entry) => deriveMessageRole(entry.info).role === 'assistant'); + const userMessages = sessionMessages.filter((entry) => deriveMessageRole(entry.info).isUser); + + let contextMessage: SessionMessage | null = null; + for (let i = assistantMessages.length - 1; i >= 0; i -= 1) { + const message = assistantMessages[i]; + if (extractTokenBreakdown(message).total > 0) { + contextMessage = message; + break; + } + } + + const tokenBreakdown = contextMessage ? extractTokenBreakdown(contextMessage) : EMPTY_BREAKDOWN; + + const totalAssistantCost = assistantMessages.reduce((sum, message) => { + const cost = toNonNegativeNumber((message.info as { cost?: unknown }).cost); + return sum + cost; + }, 0); + + const latestAssistantInfo = (contextMessage?.info ?? null) as (Message & { providerID?: string; modelID?: string }) | null; + const providerModel = resolveProviderAndModel( + providers as ProviderLike[], + latestAssistantInfo?.providerID || '', + latestAssistantInfo?.modelID || '', + ); + + const contextLimit = providerModel.contextLimit; + const usagePercent = contextLimit && contextLimit > 0 + ? Math.min(999, (tokenBreakdown.total / contextLimit) * 100) + : 0; + + const systemPrompt = ([...sessionMessages].reverse().find( + (entry) => deriveMessageRole(entry.info).isUser && typeof (entry.info as { system?: unknown }).system === 'string', + )?.info as { system?: string } | undefined)?.system || ''; + + const computedBreakdown = computeContextBreakdown(sessionMessages, systemPrompt); + + const userTokens = computedBreakdown.user; + const assistantTokens = computedBreakdown.assistant; + const toolTokens = computedBreakdown.tool; + const otherTokens = Math.max(0, tokenBreakdown.input - userTokens - assistantTokens - toolTokens); + const breakdownTotal = userTokens + assistantTokens + toolTokens + otherTokens; + + const firstMessageTs = sessionMessages[0]?.info?.time?.created; + const lastMessageTs = sessionMessages.length > 0 + ? sessionMessages[sessionMessages.length - 1]?.info?.time?.created + : null; + + return { + sessionTitle: currentSession?.title || 'Untitled Session', + messagesCount: sessionMessages.length, + userMessagesCount: userMessages.length, + assistantMessagesCount: assistantMessages.length, + createdAt: (currentSession?.time?.created ?? firstMessageTs ?? null) as number | null, + lastActivityAt: (lastMessageTs ?? currentSession?.time?.created ?? null) as number | null, + providerModel, + tokenBreakdown, + usagePercent, + totalAssistantCost, + contextLimit, + breakdown: { + user: userTokens, + assistant: assistantTokens, + tool: toolTokens, + other: otherTokens, + }, + breakdownTotal, + }; + }, [currentSessionId, providers, sessionMessages, sessions]); + + if (!currentSessionId) { + return ( +
+ Open a session to inspect context. +
+ ); + } + + const segments: Array<{ key: string; label: string; value: number; color: string }> = [ + { key: 'user', label: 'User', value: viewModel.breakdown.user, color: 'var(--status-success)' }, + { key: 'assistant', label: 'Assistant', value: viewModel.breakdown.assistant, color: 'var(--primary-base)' }, + { key: 'tool', label: 'Tool Calls', value: viewModel.breakdown.tool, color: 'var(--status-warning)' }, + { key: 'other', label: 'Other', value: viewModel.breakdown.other, color: 'var(--surface-muted-foreground)' }, + ]; + + return ( +
+
+ + {/* ── Session header ── */} +
+

{viewModel.sessionTitle}

+
+ {viewModel.providerModel.providerName} / {viewModel.providerModel.modelName} + {viewModel.createdAt && ( + <> + · + {formatDateTime(viewModel.createdAt)} + + )} +
+
+ + {/* ── Context usage ── */} +
+
+ Context + + {formatNumber(viewModel.tokenBreakdown.total)} + {viewModel.contextLimit ? ` / ${formatNumber(viewModel.contextLimit)}` : ''} + +
+
+ {viewModel.usagePercent > 0 && ( +
80 ? 'var(--status-warning)' : 'var(--primary-base)', + }} + /> + )} +
+
+ {viewModel.usagePercent.toFixed(1)}% used +
+
+ + {/* ── Stat grid ── */} +
+ {([ + { label: 'Messages', value: formatNumber(viewModel.messagesCount) }, + { label: 'User', value: formatNumber(viewModel.userMessagesCount) }, + { label: 'Assistant', value: formatNumber(viewModel.assistantMessagesCount) }, + { label: 'Cost', value: formatMoney(viewModel.totalAssistantCost) }, + ] as const).map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ + {/* ── Last turn tokens ── */} +
+
Last Assistant Message
+
+ {([ + { label: 'Input', value: viewModel.tokenBreakdown.input }, + { label: 'Output', value: viewModel.tokenBreakdown.output }, + { label: 'Reasoning', value: viewModel.tokenBreakdown.reasoning }, + { label: 'Cache Read', value: viewModel.tokenBreakdown.cacheRead }, + { label: 'Cache Write', value: viewModel.tokenBreakdown.cacheWrite }, + ] as const).map((item) => ( +
+
{item.label}
+
{formatNumber(item.value)}
+
+ ))} +
+
+ + {/* ── Context breakdown ── */} +
+
+ {segments.map((segment) => { + if (segment.value <= 0 || viewModel.breakdownTotal <= 0) return null; + return ( +
+ ); + })} +
+
+ {segments.map((segment) => { + const pct = viewModel.breakdownTotal > 0 ? (segment.value / viewModel.breakdownTotal) * 100 : 0; + return ( +
+ + + {segment.label} {pct.toFixed(0)}% + +
+ ); + })} +
+
+ + {/* ── Raw messages ── */} +
+
Raw Messages
+
+ {[...sessionMessages].reverse().map((message) => { + const role = deriveMessageRole(message.info).role; + const isExpanded = expandedRawMessages[message.info.id] === true; + const isCopied = copiedRawMessageId === message.info.id; + const messageCreatedAt = (message.info.time?.created ?? null) as number | null; + + const jsonValue = isExpanded + ? JSON.stringify({ info: message.info, parts: message.parts }, null, 2) + : ''; + + return ( +
+ + + {isExpanded && ( +
+
+
+ +
+ + {jsonValue} + +
+
+ )} +
+ ); + })} +
+
+
+
+ ); +}; diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 0a67fd38..5929a237 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -141,6 +141,9 @@ export const Header: React.FC = () => { const toggleSidebar = useUIStore((state) => state.toggleSidebar); const toggleBottomTerminal = useUIStore((state) => state.toggleBottomTerminal); const toggleRightSidebar = useUIStore((state) => state.toggleRightSidebar); + const openContextOverview = useUIStore((state) => state.openContextOverview); + const closeContextPanel = useUIStore((state) => state.closeContextPanel); + const contextPanelByDirectory = useUIStore((state) => state.contextPanelByDirectory); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const activeMainTab = useUIStore((state) => state.activeMainTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); @@ -338,9 +341,15 @@ export const Header: React.FC = () => { const updateProjectTabsOverflow = React.useCallback(() => { const el = projectTabsScrollRef.current; if (!el) return; - setProjectTabsOverflow({ + const next = { left: el.scrollLeft > 2, right: el.scrollLeft + el.clientWidth < el.scrollWidth - 2, + }; + setProjectTabsOverflow((prev) => { + if (prev.left === next.left && prev.right === next.right) { + return prev; + } + return next; }); }, []); @@ -884,6 +893,30 @@ export const Header: React.FC = () => { setSettingsDialogOpen(true); }, [blurActiveElement, isMobile, setSessionSwitcherOpen, setSettingsDialogOpen]); + const handleOpenContextPanel = React.useCallback(() => { + const directory = normalize(openDirectory || ''); + if (!directory) { + return; + } + + const panelState = contextPanelByDirectory[directory]; + if (panelState?.isOpen && panelState.mode === 'context') { + closeContextPanel(directory); + return; + } + + openContextOverview(directory); + }, [closeContextPanel, contextPanelByDirectory, openContextOverview, openDirectory]); + + const isContextPanelActive = React.useMemo(() => { + const directory = normalize(openDirectory || ''); + if (!directory) { + return false; + } + const panelState = contextPanelByDirectory[directory]; + return Boolean(panelState?.isOpen && panelState.mode === 'context'); + }, [contextPanelByDirectory, openDirectory]); + const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 rounded-md typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground hover:bg-interactive-hover transition-colors'; const desktopPaddingClass = React.useMemo(() => { @@ -1604,9 +1637,11 @@ export const Header: React.FC = () => { size="compact" hideIcon showPercentIcon + onClick={handleOpenContextPanel} + pressed={isContextPanelActive} className="mr-3.5" valueClassName="typography-ui-label font-medium leading-none text-foreground" - percentIconClassName="h-5 w-5 text-muted-foreground" + percentIconClassName="h-5 w-5" /> )} diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index d7582825..17745518 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -1290,11 +1290,21 @@ export const SessionSidebar: React.FC = ({ sortedWorktrees.forEach((meta) => { const directory = normalizePath(meta.path) ?? meta.path; - const label = meta.label || meta.name || formatDirectoryName(directory, homeDirectory) || directory; + const currentBranch = gitDirectories.get(directory)?.status?.current?.trim() || null; + const metadataBranch = meta.branch?.trim() || null; + const shouldSyncLabelWithBranch = Boolean( + currentBranch + && metadataBranch + && meta.label + && normalizeForBranchComparison(meta.label) === normalizeForBranchComparison(metadataBranch), + ); + const label = shouldSyncLabelWithBranch + ? currentBranch! + : (meta.label || meta.name || formatDirectoryName(directory, homeDirectory) || directory); groups.push({ id: `worktree:${directory}`, label, - branch: meta.branch || null, + branch: currentBranch || metadataBranch, description: formatPathForDisplay(directory, homeDirectory), isMain: false, worktree: meta, @@ -1309,10 +1319,11 @@ export const SessionSidebar: React.FC = ({ .sort((a, b) => (groupOrder.get(a) ?? 0) - (groupOrder.get(b) ?? 0)); orphanKeys.forEach((directory) => { + const currentBranch = gitDirectories.get(directory)?.status?.current?.trim() || null; groups.push({ id: `worktree:orphan:${directory}`, label: formatDirectoryName(directory, homeDirectory) || directory, - branch: null, + branch: currentBranch, description: formatPathForDisplay(directory, homeDirectory), isMain: false, worktree: null, @@ -1323,7 +1334,7 @@ export const SessionSidebar: React.FC = ({ return groups; }, - [homeDirectory, worktreeMetadata, pinnedSessionIds] + [homeDirectory, worktreeMetadata, pinnedSessionIds, gitDirectories] ); const toggleGroupSessionLimit = React.useCallback((groupId: string) => { diff --git a/packages/ui/src/components/ui/ContextUsageDisplay.tsx b/packages/ui/src/components/ui/ContextUsageDisplay.tsx index 3bb801d3..79160c17 100644 --- a/packages/ui/src/components/ui/ContextUsageDisplay.tsx +++ b/packages/ui/src/components/ui/ContextUsageDisplay.tsx @@ -16,6 +16,8 @@ interface ContextUsageDisplayProps { className?: string; valueClassName?: string; percentIconClassName?: string; + onClick?: () => void; + pressed?: boolean; } export const ContextUsageDisplay: React.FC = ({ @@ -30,6 +32,8 @@ export const ContextUsageDisplay: React.FC = ({ className, valueClassName, percentIconClassName, + onClick, + pressed = false, }) => { const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState(false); @@ -56,16 +60,10 @@ export const ContextUsageDisplay: React.FC = ({ `Output limit: ${formatTokens(safeOutputLimit)}`, ]; - const contextElement = ( -
setMobileTooltipOpen(true) : undefined} - > + const isInteractive = !isMobile && typeof onClick === 'function'; + + const contextContent = ( + <> {!isMobile && !hideIcon && } {showPercentIcon ? ( @@ -82,6 +80,39 @@ export const ContextUsageDisplay: React.FC = ({ )} + + ); + + const sharedClassName = cn( + 'app-region-no-drag flex items-center gap-1.5 select-none', + size === 'compact' ? 'typography-micro' : 'typography-meta', + isInteractive + ? cn( + 'rounded-md px-2 py-1.5 text-foreground transition-colors', + 'hover:bg-interactive-hover', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary' + ) + : 'text-muted-foreground/60', + className, + ); + + const contextElement = isInteractive ? ( + + ) : ( +
setMobileTooltipOpen(true) : undefined} + > + {contextContent}
); diff --git a/packages/ui/src/components/ui/ErrorBoundary.tsx b/packages/ui/src/components/ui/ErrorBoundary.tsx index abe5a07e..4101991b 100644 --- a/packages/ui/src/components/ui/ErrorBoundary.tsx +++ b/packages/ui/src/components/ui/ErrorBoundary.tsx @@ -7,6 +7,7 @@ interface ErrorBoundaryState { hasError: boolean; error?: Error; errorInfo?: React.ErrorInfo; + copied?: boolean; } interface ErrorBoundaryProps { @@ -25,17 +26,32 @@ export class ErrorBoundary extends React.Component { this.setState({ hasError: false, error: undefined, errorInfo: undefined }); }; + handleCopy = async () => { + const errorText = this.state.error ? String(this.state.error) : 'Unknown error'; + const stack = this.state.error?.stack ? `\n\nStack:\n${this.state.error.stack}` : ''; + const componentStack = this.state.errorInfo?.componentStack ? `\n\nComponent stack:${this.state.errorInfo.componentStack}` : ''; + const payload = `${errorText}${stack}${componentStack}`; + + try { + await navigator.clipboard.writeText(payload); + this.setState({ copied: true }); + window.setTimeout(() => { + this.setState((prev) => (prev.copied ? { copied: false } : null)); + }, 1500); + } catch { + // ignore + } + }; + render() { if (this.state.hasError) { if (this.props.fallback) { @@ -61,6 +77,7 @@ export class ErrorBoundary extends React.ComponentError details
                     {this.state.error.toString()}
+                    {this.state.errorInfo?.componentStack ? `\n\nComponent stack:${this.state.errorInfo.componentStack}` : ''}
                   
)} @@ -70,6 +87,9 @@ export class ErrorBoundary extends React.Component Try again +
diff --git a/packages/ui/src/hooks/useChatScrollManager.ts b/packages/ui/src/hooks/useChatScrollManager.ts index 53863505..2257f034 100644 --- a/packages/ui/src/hooks/useChatScrollManager.ts +++ b/packages/ui/src/hooks/useChatScrollManager.ts @@ -66,6 +66,7 @@ const PIN_THRESHOLD_RATIO = 0.10; export const useChatScrollManager = ({ currentSessionId, sessionMessages, + streamingMessageId, updateViewportAnchor, isSyncing, isMobile, @@ -117,6 +118,15 @@ export const useChatScrollManager = ({ scrollEngine.scrollToPosition(Math.max(0, bottom), options); }, [markProgrammaticScroll, scrollEngine]); + const scrollPinnedToBottom = React.useCallback(() => { + if (streamingMessageId) { + scrollToBottomInternal({ followBottom: true }); + return; + } + + scrollToBottomInternal({ instant: true }); + }, [scrollToBottomInternal, streamingMessageId]); + const updateScrollButtonVisibility = React.useCallback(() => { const container = scrollRef.current; if (!container) { @@ -250,13 +260,12 @@ export const useChatScrollManager = ({ const container = scrollRef.current; if (!container) return; - // When pinned and content grows, scroll to bottom instantly + // When pinned and content grows, follow bottom with fast smooth scroll const distanceFromBottom = getDistanceFromBottom(); if (distanceFromBottom > getPinThreshold()) { - markProgrammaticScroll(); - scrollToBottomInternal({ instant: true }); + scrollPinnedToBottom(); } - }, [getDistanceFromBottom, getPinThreshold, isSyncing, markProgrammaticScroll, scrollToBottomInternal, sessionMessages]); + }, [getDistanceFromBottom, getPinThreshold, isSyncing, scrollPinnedToBottom, sessionMessages]); // Use ResizeObserver to detect content changes and maintain pin React.useEffect(() => { @@ -266,11 +275,11 @@ export const useChatScrollManager = ({ const observer = new ResizeObserver(() => { updateScrollButtonVisibility(); - // Maintain pin when content grows - always instant for smooth experience + // Maintain pin when content grows - fast smooth follow if (isPinnedRef.current) { const distanceFromBottom = getDistanceFromBottom(); if (distanceFromBottom > getPinThreshold()) { - scrollToBottomInternal({ instant: true }); + scrollPinnedToBottom(); } } }); @@ -282,7 +291,7 @@ export const useChatScrollManager = ({ if (isPinnedRef.current) { const distanceFromBottom = getDistanceFromBottom(); if (distanceFromBottom > getPinThreshold()) { - scrollToBottomInternal({ instant: true }); + scrollPinnedToBottom(); } } }); @@ -293,7 +302,7 @@ export const useChatScrollManager = ({ observer.disconnect(); childObserver.disconnect(); }; - }, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]); + }, [getDistanceFromBottom, getPinThreshold, scrollPinnedToBottom, updateScrollButtonVisibility]); React.useEffect(() => { if (typeof window === 'undefined') { @@ -315,14 +324,14 @@ export const useChatScrollManager = ({ const handleMessageContentChange = React.useCallback(() => { updateScrollButtonVisibility(); - // Maintain pin when content changes - always instant + // Maintain pin when content changes - fast smooth follow if (isPinnedRef.current) { const distanceFromBottom = getDistanceFromBottom(); if (distanceFromBottom > getPinThreshold()) { - scrollToBottomInternal({ instant: true }); + scrollPinnedToBottom(); } } - }, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]); + }, [getDistanceFromBottom, getPinThreshold, scrollPinnedToBottom, updateScrollButtonVisibility]); const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => { const existing = animationHandlersRef.current.get(messageId); @@ -336,7 +345,7 @@ export const useChatScrollManager = ({ if (isPinnedRef.current) { const distanceFromBottom = getDistanceFromBottom(); if (distanceFromBottom > getPinThreshold()) { - scrollToBottomInternal({ instant: true }); + scrollPinnedToBottom(); } } }, @@ -350,7 +359,7 @@ export const useChatScrollManager = ({ if (isPinnedRef.current) { const distanceFromBottom = getDistanceFromBottom(); if (distanceFromBottom > getPinThreshold()) { - scrollToBottomInternal({ instant: true }); + scrollPinnedToBottom(); } } }, @@ -360,7 +369,7 @@ export const useChatScrollManager = ({ animationHandlersRef.current.set(messageId, handlers); return handlers; - }, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]); + }, [getDistanceFromBottom, getPinThreshold, scrollPinnedToBottom, updateScrollButtonVisibility]); return { scrollRef, diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index 18b07601..53169af1 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -818,7 +818,6 @@ export const useEventStream = () => { type: part.type || 'text', } as Part; - // Fallback: if we see assistant parts but session.status hasn't arrived yet, mark busy. if (roleInfo === 'assistant') { const partType = (messagePart as { type?: unknown }).type; const partTime = (messagePart as { time?: { end?: unknown } }).time; @@ -863,6 +862,81 @@ export const useEventStream = () => { break; } + case 'message.part.delta': { + const sessionId = readStringProp(props, ['sessionID', 'sessionId']); + const messageId = readStringProp(props, ['messageID', 'messageId']); + const partId = readStringProp(props, ['partID', 'partId']); + const field = readStringProp(props, ['field']); + const delta = typeof props.delta === 'string' ? props.delta : null; + + if (!sessionId || !messageId || !partId || !field || delta === null) { + if (streamDebugEnabled()) { + console.debug('[useEventStream] Skipping message.part.delta with missing payload', { + sessionID: props.sessionID, + messageID: props.messageID, + partID: props.partID, + field: props.field, + }); + } + break; + } + + lastMessageEventBySessionRef.current.set(sessionId, Date.now()); + const pendingTimer = pendingMessageStallTimersRef.current.get(sessionId); + if (pendingTimer) { + clearTimeout(pendingTimer); + pendingMessageStallTimersRef.current.delete(sessionId); + } + + const trimmedHeadMaxId = useSessionStore.getState().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId; + if (trimmedHeadMaxId && !isIdNewer(messageId, trimmedHeadMaxId)) { + if (streamDebugEnabled()) { + console.debug('[useEventStream] Skipping message.part.delta for trimmed message', { + sessionId, + messageId, + trimmedHeadMaxId, + }); + } + break; + } + + const existingMessage = getMessageFromStore(sessionId, messageId); + const existingPart = existingMessage?.parts?.find((item) => item?.id === partId); + if (!existingPart) { + break; + } + + const existingPartRecord = existingPart as Record; + const existingFieldValue = existingPartRecord[field]; + const updatedPart: Part = { + ...existingPart, + [field]: `${typeof existingFieldValue === 'string' ? existingFieldValue : ''}${delta}`, + } as Part; + + let roleInfo = 'assistant'; + const existingRole = (existingMessage?.info as Record | undefined)?.role; + if (typeof existingRole === 'string') { + roleInfo = existingRole; + } + + if (roleInfo === 'assistant' && delta.length > 0) { + const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId); + const recentlyConfirmedIdle = + currentStatus?.type === 'idle' && + typeof currentStatus.confirmedAt === 'number' && + Date.now() - currentStatus.confirmedAt < 1200; + if (!currentStatus || currentStatus.type === 'idle') { + if (!recentlyConfirmedIdle) { + updateSessionStatus(sessionId, { type: 'busy' }, 'sse:message.part.delta'); + } + } + } + + trackMessage(messageId, 'part_delta_received', { role: roleInfo, field }); + addStreamingPart(sessionId, messageId, updatedPart, roleInfo); + break; + } + case 'message.updated': { const message = (typeof props.info === 'object' && props.info !== null) ? (props.info as Record) : props; const messageExt = message as Record; diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 5da44cd0..d421a514 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -864,6 +864,11 @@ html:not(.dark) .chat-scroll { contain-intrinsic-size: none !important; } +/* Mermaid block: position context for controls wrapper. */ +[data-streamdown="mermaid-block"] { + position: relative; +} + /* Hide system caret in terminal - targets both Ghostty internal input and our custom touch input */ .terminal-viewport-container textarea, .terminal-viewport-container input { diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index c7219548..0cd3c1d0 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -487,3 +487,18 @@ export const fetchDesktopInstalledApps = async ( return { apps: [], success: false, hasCache: false, isCacheStale: false }; } }; + +export const clearDesktopCache = async (): Promise => { + if (!isTauriShell() || !isDesktopLocalOriginActive()) { + return false; + } + + try { + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + await tauri?.core?.invoke?.('desktop_clear_cache'); + return true; + } catch (error) { + console.warn('Failed to clear cache', error); + return false; + } +}; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 2c28e850..6bc673d3 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -6,7 +6,7 @@ import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files'; export type RightSidebarTab = 'git' | 'files'; -export type ContextPanelMode = 'diff' | 'file'; +export type ContextPanelMode = 'diff' | 'file' | 'context'; type ContextPanelDirectoryState = { isOpen: boolean; @@ -224,6 +224,7 @@ interface UIStore { setRightSidebarTab: (tab: RightSidebarTab) => void; openContextDiff: (directory: string, filePath: string) => void; openContextFile: (directory: string, filePath: string) => void; + openContextOverview: (directory: string) => void; closeContextPanel: (directory: string) => void; toggleContextPanelExpanded: (directory: string) => void; setContextPanelWidth: (directory: string, width: number) => void; @@ -404,6 +405,18 @@ export const useUIStore = create()( setSidebarOpen: (open) => { set((state) => { + if (state.isSidebarOpen === open) { + if (!open) { + return state; + } + if (!state.hasManuallyResizedLeftSidebar && state.sidebarWidth !== LEFT_SIDEBAR_MIN_WIDTH) { + return { + isSidebarOpen: open, + sidebarWidth: LEFT_SIDEBAR_MIN_WIDTH, + }; + } + return state; + } if (open && !state.hasManuallyResizedLeftSidebar) { return { isSidebarOpen: open, @@ -434,6 +447,18 @@ export const useUIStore = create()( setRightSidebarOpen: (open) => { set((state) => { + if (state.isRightSidebarOpen === open) { + if (!open) { + return state; + } + if (!state.hasManuallyResizedRightSidebar && state.rightSidebarWidth !== RIGHT_SIDEBAR_MIN_WIDTH) { + return { + isRightSidebarOpen: open, + rightSidebarWidth: RIGHT_SIDEBAR_MIN_WIDTH, + }; + } + return state; + } if (open && !state.hasManuallyResizedRightSidebar) { return { isRightSidebarOpen: open, @@ -501,6 +526,29 @@ export const useUIStore = create()( }); }, + openContextOverview: (directory) => { + const normalizedDirectory = normalizeDirectoryPath((directory || '').trim()); + if (!normalizedDirectory) { + return; + } + + set((state) => { + const prev = state.contextPanelByDirectory[normalizedDirectory]; + const current = touchContextPanelState(prev); + const byDirectory = { + ...state.contextPanelByDirectory, + [normalizedDirectory]: { + ...current, + isOpen: true, + mode: 'context' as const, + targetPath: null, + }, + }; + + return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) }; + }); + }, + closeContextPanel: (directory) => { const normalizedDirectory = normalizeDirectoryPath((directory || '').trim()); if (!normalizedDirectory) { @@ -585,7 +633,25 @@ export const useUIStore = create()( }, setBottomTerminalOpen: (open) => { - set(() => { + set((state) => { + if (state.isBottomTerminalOpen === open) { + if (!open) { + return state; + } + if (!state.hasManuallyResizedBottomTerminal && typeof window !== 'undefined') { + const proportionalHeight = Math.floor(window.innerHeight * 0.32); + if (state.bottomTerminalHeight === proportionalHeight && state.hasManuallyResizedBottomTerminal === false) { + return state; + } + return { + isBottomTerminalOpen: open, + bottomTerminalHeight: proportionalHeight, + hasManuallyResizedBottomTerminal: false, + }; + } + return state; + } + if (open && typeof window !== 'undefined') { const proportionalHeight = Math.floor(window.innerHeight * 0.32); return { diff --git a/packages/ui/src/types/streamdown.d.ts b/packages/ui/src/types/streamdown.d.ts index cc3587c5..78644b41 100644 --- a/packages/ui/src/types/streamdown.d.ts +++ b/packages/ui/src/types/streamdown.d.ts @@ -4,6 +4,13 @@ declare module 'streamdown' { export interface StreamdownProps { children: string; mode?: 'streaming' | 'static'; + isAnimating?: boolean; + animated?: { + animation?: string; + duration?: number; + easing?: string; + sep?: 'word' | 'char'; + }; className?: string; shikiTheme?: readonly [string | object, string | object]; @@ -11,8 +18,15 @@ declare module 'streamdown' { controls?: boolean | { code?: boolean; table?: boolean; - mermaid?: boolean; + mermaid?: boolean | { + download?: boolean; + copy?: boolean; + fullscreen?: boolean; + panZoom?: boolean; + }; }; + plugins?: Record; + mermaid?: Record; components?: { [key: string]: ComponentType<{ children?: ReactNode; className?: string; [key: string]: unknown }>; }; diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 967eec70..2821be72 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -229,7 +229,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.1.65", + "@opencode-ai/sdk": "^1.2.5", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/vscode/src/AgentManagerPanelProvider.ts b/packages/vscode/src/AgentManagerPanelProvider.ts index 1ca10988..b5dafa98 100644 --- a/packages/vscode/src/AgentManagerPanelProvider.ts +++ b/packages/vscode/src/AgentManagerPanelProvider.ts @@ -430,7 +430,7 @@ const deriveSessionActivity = (payload: Record | null): Session } } - if (type === 'message.part.updated') { + if (type === 'message.part.updated' || type === 'message.part.delta') { const info = properties?.info as Record | undefined; const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId; const role = info?.role; diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts index 6fdaf8c1..4cadab28 100644 --- a/packages/vscode/src/ChatViewProvider.ts +++ b/packages/vscode/src/ChatViewProvider.ts @@ -462,7 +462,7 @@ const deriveSessionActivity = (payload: Record | null): Session } } - if (type === 'message.part.updated') { + if (type === 'message.part.updated' || type === 'message.part.delta') { const info = properties?.info as Record | undefined; const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId; const role = info?.role; diff --git a/packages/vscode/src/sessionActivityWatcher.ts b/packages/vscode/src/sessionActivityWatcher.ts index 82423d3e..fe6ef0e2 100644 --- a/packages/vscode/src/sessionActivityWatcher.ts +++ b/packages/vscode/src/sessionActivityWatcher.ts @@ -82,7 +82,7 @@ const deriveSessionActivity = (payload: Record): SessionActivit } } - if (type === 'message.updated' || type === 'message.part.updated') { + if (type === 'message.updated' || type === 'message.part.updated' || type === 'message.part.delta') { const info = properties?.info as Record | undefined; const sessionId = (info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId) as string; const role = info?.role as string; diff --git a/packages/web/package.json b/packages/web/package.json index bc531a95..d0bd9754 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -26,7 +26,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.65", + "@opencode-ai/sdk": "^1.2.5", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/web/server/index.js b/packages/web/server/index.js index ae6ee87b..cf030305 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -3937,7 +3937,7 @@ function deriveSessionActivityTransitions(payload) { } } - if (payload.type === 'message.part.updated') { + if (payload.type === 'message.part.updated' || payload.type === 'message.part.delta') { const info = payload.properties?.info; const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId; const role = info?.role; @@ -11177,7 +11177,6 @@ Context: res.json({ success: true, killedCount }); }); - try { syncFromHmrState(); if (await isOpenCodeProcessHealthy()) {