feat: improve chat streaming UX and add Mermaid diagram rendering (#438)
* feat: show current branch in empty chat state * fix: display current git branch for worktrees and update them with branch change * refactor: improve read tool output parsing with structured data * feat: add support for message part delta events * fix(chat): improve streaming rendering, scroll behavior, and assistant action visibility * feat: Add mermaid diagram support to chat markdown rendering * fix: update table download functionality to include success notification and remove unused MarkdownRenderer import * refactor: streamline Streamdown component props for improved readability * fix: preserve Streamdown code-block markers and use native Tauri cache clearing * feat: add context overview panel to view conversation details
This commit is contained in:
committed by
GitHub
parent
138772e66e
commit
4d71bb27eb
@@ -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=="],
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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<R: tauri::Runtime>(
|
||||
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<R: tauri::Runtime>(
|
||||
&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<String> = 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<String>) -> 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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<ChatEmptyState />
|
||||
<ChatEmptyState showDraftContext />
|
||||
</div>
|
||||
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
|
||||
<ChatInput scrollToBottom={scrollToBottom} />
|
||||
|
||||
@@ -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<ChatEmptyStateProps> = ({
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center min-h-full w-full gap-6">
|
||||
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||
{showDraftContext && (
|
||||
<div className="max-w-[calc(100%-2rem)] flex flex-col items-center gap-1">
|
||||
{branchName && (
|
||||
<div className="inline-flex items-center gap-1 text-body-md" style={{ color: textColor }}>
|
||||
<RiGitBranchLine className="h-4 w-4 shrink-0" />
|
||||
<span className="overflow-hidden whitespace-nowrap" title={branchName}>{branchName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<TextLoop
|
||||
className="text-body-md"
|
||||
interval={4}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Streamdown } from 'streamdown';
|
||||
import { code } from '@streamdown/code';
|
||||
import { mermaid } from '@streamdown/mermaid';
|
||||
import 'streamdown/styles.css';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
|
||||
import { toast } from '@/components/ui';
|
||||
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
@@ -116,6 +120,45 @@ const useMarkdownShikiThemes = (): readonly [string | object, string | object] =
|
||||
return isVSCode ? themes : fallbackThemes;
|
||||
};
|
||||
|
||||
const useStreamdownMermaidOptions = () => {
|
||||
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<HTMLDivElement |
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleDownload = (format: 'csv' | 'markdown') => {
|
||||
const tableEl = tableRef.current?.querySelector('table');
|
||||
if (!tableEl) return;
|
||||
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);
|
||||
}
|
||||
};
|
||||
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 (
|
||||
<div className="relative" ref={menuRef}>
|
||||
@@ -348,9 +392,35 @@ type CodeBlockWrapperProps = React.HTMLAttributes<HTMLPreElement> & {
|
||||
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<string, unknown> | 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<string, unknown> | undefined;
|
||||
if (typeof innerProps?.children === 'string') source = innerProps.children;
|
||||
}
|
||||
return { isMermaid: true, source };
|
||||
};
|
||||
|
||||
const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className, style, ...props }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const codeRef = React.useRef<HTMLDivElement>(null);
|
||||
const mermaidInfo = getMermaidInfo(children);
|
||||
const codeChild = React.useMemo(
|
||||
() => (
|
||||
React.isValidElement(children)
|
||||
? React.cloneElement(children as React.ReactElement<Record<string, unknown>>, { 'data-block': true })
|
||||
: children
|
||||
),
|
||||
[children],
|
||||
);
|
||||
|
||||
const normalizedStyle = React.useMemo<React.CSSProperties | undefined>(() => {
|
||||
if (!style) return style;
|
||||
@@ -396,6 +466,11 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
|
||||
return next;
|
||||
}, [style]);
|
||||
|
||||
// Mermaid blocks get their own controls via MermaidWrapper — skip the code copy button.
|
||||
if (mermaidInfo.isMermaid) {
|
||||
return <MermaidWrapper source={mermaidInfo.source}>{codeChild}</MermaidWrapper>;
|
||||
}
|
||||
|
||||
const getCodeContent = (): string => {
|
||||
if (!codeRef.current) return '';
|
||||
const codeEl = codeRef.current.querySelector('code');
|
||||
@@ -422,7 +497,7 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
|
||||
className={cn(className)}
|
||||
style={normalizedStyle}
|
||||
>
|
||||
{children}
|
||||
{codeChild}
|
||||
</pre>
|
||||
<div className="absolute top-1 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
@@ -442,6 +517,88 @@ const streamdownComponents = {
|
||||
table: TableWrapper,
|
||||
};
|
||||
|
||||
const streamdownPlugins = {
|
||||
code,
|
||||
mermaid,
|
||||
};
|
||||
|
||||
const streamdownControls = {
|
||||
code: false,
|
||||
table: false,
|
||||
mermaid: {
|
||||
download: false,
|
||||
copy: false,
|
||||
fullscreen: false,
|
||||
panZoom: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Mermaid copy button — copies raw mermaid source
|
||||
const MermaidCopyButton: React.FC<{ source: string }> = ({ source }) => {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!source) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(source);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy diagram:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Copy diagram source"
|
||||
>
|
||||
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Mermaid download button — downloads rendered SVG
|
||||
const MermaidDownloadButton: React.FC<{ containerRef: React.RefObject<HTMLDivElement | null> }> = ({ 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('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
|
||||
}
|
||||
downloadFile('diagram.svg', markup, 'image/svg+xml');
|
||||
toast.success('Diagram downloaded');
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Download diagram"
|
||||
>
|
||||
<RiDownloadLine className="size-3.5" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Mermaid wrapper with custom controls (same pattern as TableWrapper)
|
||||
const MermaidWrapper: React.FC<{ children: React.ReactNode; source: string }> = ({ children, source }) => {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div className="group relative" ref={containerRef}>
|
||||
{children}
|
||||
<div className="absolute top-1 right-2 z-20 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<MermaidDownloadButton containerRef={containerRef} />
|
||||
<MermaidCopyButton source={source} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type MarkdownVariant = 'assistant' | 'tool';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
@@ -464,6 +621,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
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<MarkdownRendererProps> = ({
|
||||
const markdownContent = (
|
||||
<div className={cn('break-words', className)}>
|
||||
<Streamdown
|
||||
mode={isStreaming ? 'streaming' : 'static'}
|
||||
shikiTheme={shikiThemes}
|
||||
className={streamdownClassName}
|
||||
controls={{ code: false, table: false }}
|
||||
components={streamdownComponents}
|
||||
>
|
||||
key={`streamdown-${componentKey}-${mermaidRenderKey}`}
|
||||
mode={isStreaming ? 'streaming' : 'static'}
|
||||
shikiTheme={shikiThemes}
|
||||
className={streamdownClassName}
|
||||
controls={streamdownControls}
|
||||
plugins={streamdownPlugins}
|
||||
mermaid={mermaidOptions}
|
||||
components={streamdownComponents}
|
||||
>
|
||||
{content}
|
||||
</Streamdown>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className={cn('break-words', className)}>
|
||||
<Streamdown
|
||||
key={`streamdown-simple-${mermaidRenderKey}`}
|
||||
mode="static"
|
||||
shikiTheme={shikiThemes}
|
||||
className={streamdownClassName}
|
||||
controls={{ code: false, table: false }}
|
||||
controls={streamdownControls}
|
||||
plugins={streamdownPlugins}
|
||||
mermaid={mermaidOptions}
|
||||
components={streamdownComponents}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
renderWebSearchOutput,
|
||||
formatInputForDisplay,
|
||||
parseDiffToUnified,
|
||||
parseReadToolOutput,
|
||||
type UnifiedDiffHunk,
|
||||
type SideBySideDiffHunk,
|
||||
type SideBySideDiffLine,
|
||||
@@ -740,33 +741,41 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ 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<string, unknown>) : {};
|
||||
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 (
|
||||
<div>
|
||||
{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 (
|
||||
<div key={idx} className={`typography-code font-mono flex ${isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
|
||||
<div key={idx} className={`typography-code font-mono flex ${line.isInfo ? 'text-muted-foreground/70 italic' : ''}`}>
|
||||
<span className="w-12 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
|
||||
{shouldShowLineNumber ? lineNumber : ''}
|
||||
{shouldShowLineNumber ? effectiveLineNumber : ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words">{line}</div>
|
||||
{line.isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words">{line.text}</div>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
@@ -794,7 +803,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
},
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
{line.text}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
formatEditOutput,
|
||||
detectLanguageFromOutput,
|
||||
formatInputForDisplay,
|
||||
parseReadToolOutput,
|
||||
} from '../toolRenderers';
|
||||
|
||||
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
|
||||
@@ -808,31 +809,40 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = 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(
|
||||
<div className="typography-code w-full min-w-0 space-y-1">
|
||||
{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 (
|
||||
<div key={idx} className={cn('typography-code font-mono flex w-full min-w-0', isInfo && 'text-muted-foreground/70 italic')}>
|
||||
<div key={idx} className={cn('typography-code font-mono flex w-full min-w-0', line.isInfo && 'text-muted-foreground/70 italic')}>
|
||||
<span className="w-10 flex-shrink-0 text-right pr-3 select-none border-r mr-3 -my-0.5 py-0.5" style={{ color: 'var(--tools-edit-line-number)', borderColor: 'var(--tools-border)' }}>
|
||||
{shouldShowLineNumber ? lineNumber : ''}
|
||||
{shouldShowLineNumber ? effectiveLineNumber : ''}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words">{line}</div>
|
||||
{line.isInfo ? (
|
||||
<div className="whitespace-pre-wrap break-words">{line.text}</div>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={detectLanguageFromOutput(formattedOutput, part.tool, input as Record<string, unknown>)}
|
||||
language={detectLanguageFromOutput(contentForLanguage, part.tool, input as Record<string, unknown>)}
|
||||
PreTag="div"
|
||||
wrapLines
|
||||
wrapLongLines
|
||||
@@ -855,7 +865,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
||||
},
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
{line.text}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
@@ -1073,6 +1083,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ 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<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
|
||||
return inputDesc;
|
||||
}
|
||||
return null;
|
||||
}, [showTextJustificationActivity, stateWithData, input]);
|
||||
}, [showTextJustificationActivity, part.tool, stateWithData, input]);
|
||||
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
|
||||
|
||||
@@ -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(/<type>(file|directory)<\/type>/i);
|
||||
const detectedType = (typeMatch?.[1]?.toLowerCase() ?? 'unknown') as ParsedReadToolOutput['type'];
|
||||
|
||||
const contentMatch = output.match(/<content>([\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);
|
||||
|
||||
@@ -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'
|
||||
? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate />
|
||||
: panelState?.mode === 'file'
|
||||
? <FilesView mode="editor-only" />
|
||||
: panelState?.mode === 'context'
|
||||
? <ContextPanelContent />
|
||||
: null;
|
||||
|
||||
const header = (
|
||||
|
||||
@@ -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<string, unknown>).reduce<number>((sum, item) => sum + estimateTextLength(item), 0);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const estimatePartChars = (part: Part, role: 'user' | 'assistant' | 'tool' | 'other'): ContextBuckets => {
|
||||
const partRecord = part as Record<string, unknown>;
|
||||
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<ContextBuckets>((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<Record<string, boolean>>({});
|
||||
const [copiedRawMessageId, setCopiedRawMessageId] = React.useState<string | null>(null);
|
||||
const copyResetTimeoutRef = React.useRef<number | null>(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 (
|
||||
<div className="flex h-full items-center justify-center p-6 text-center typography-ui-label text-muted-foreground">
|
||||
Open a session to inspect context.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="h-full overflow-y-auto bg-background">
|
||||
<div className="mx-auto w-full max-w-[52rem] px-5 py-6">
|
||||
|
||||
{/* ── Session header ── */}
|
||||
<div className="mb-6">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">{viewModel.sessionTitle}</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 typography-micro text-muted-foreground/70">
|
||||
<span>{viewModel.providerModel.providerName} / {viewModel.providerModel.modelName}</span>
|
||||
{viewModel.createdAt && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{formatDateTime(viewModel.createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Context usage ── */}
|
||||
<div className="mb-5 rounded-lg bg-[var(--surface-elevated)]/70 px-4 py-3.5">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="typography-micro text-muted-foreground">Context</span>
|
||||
<span className="typography-micro tabular-nums text-muted-foreground/70">
|
||||
{formatNumber(viewModel.tokenBreakdown.total)}
|
||||
{viewModel.contextLimit ? ` / ${formatNumber(viewModel.contextLimit)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2.5 flex h-1 w-full overflow-hidden rounded-full bg-[var(--surface-subtle)]">
|
||||
{viewModel.usagePercent > 0 && (
|
||||
<div
|
||||
className="rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.max(0.5, viewModel.usagePercent)}%`,
|
||||
backgroundColor: viewModel.usagePercent > 80 ? 'var(--status-warning)' : 'var(--primary-base)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 typography-micro font-medium tabular-nums text-foreground/80">
|
||||
{viewModel.usagePercent.toFixed(1)}% used
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Stat grid ── */}
|
||||
<div className="mb-5 grid grid-cols-2 gap-2">
|
||||
{([
|
||||
{ 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) => (
|
||||
<div key={item.label} className="rounded-lg bg-[var(--surface-elevated)]/70 px-3 py-2.5">
|
||||
<div className="typography-micro text-muted-foreground/70">{item.label}</div>
|
||||
<div className="mt-0.5 typography-ui-label tabular-nums text-foreground">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Last turn tokens ── */}
|
||||
<div className="mb-5 rounded-lg bg-[var(--surface-elevated)]/70 px-4 py-3.5">
|
||||
<div className="typography-micro text-muted-foreground">Last Assistant Message</div>
|
||||
<div className="mt-2.5 grid grid-cols-3 gap-x-4 gap-y-2.5">
|
||||
{([
|
||||
{ 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) => (
|
||||
<div key={item.label}>
|
||||
<div className="typography-micro text-muted-foreground/70">{item.label}</div>
|
||||
<div className="mt-0.5 typography-ui-label tabular-nums text-foreground">{formatNumber(item.value)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Context breakdown ── */}
|
||||
<div className="mb-6">
|
||||
<div className="flex h-1 w-full overflow-hidden rounded-full bg-[var(--surface-subtle)]">
|
||||
{segments.map((segment) => {
|
||||
if (segment.value <= 0 || viewModel.breakdownTotal <= 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={segment.key}
|
||||
style={{
|
||||
width: `${(segment.value / viewModel.breakdownTotal) * 100}%`,
|
||||
backgroundColor: segment.color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
{segments.map((segment) => {
|
||||
const pct = viewModel.breakdownTotal > 0 ? (segment.value / viewModel.breakdownTotal) * 100 : 0;
|
||||
return (
|
||||
<div key={segment.key} className="inline-flex items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: segment.color }} />
|
||||
<span className="typography-micro text-muted-foreground/70">
|
||||
{segment.label} <span className="tabular-nums">{pct.toFixed(0)}%</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Raw messages ── */}
|
||||
<div>
|
||||
<div className="typography-micro text-muted-foreground">Raw Messages</div>
|
||||
<div className="mt-2.5 space-y-1">
|
||||
{[...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 (
|
||||
<div
|
||||
key={message.info.id}
|
||||
className="overflow-hidden rounded-lg bg-[var(--surface-elevated)]/70"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full cursor-pointer px-3 py-1.5 text-left hover:bg-[var(--interactive-hover)]"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => {
|
||||
setExpandedRawMessages((prev) => ({
|
||||
...prev,
|
||||
[message.info.id]: !(prev[message.info.id] === true),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 whitespace-nowrap overflow-hidden">
|
||||
<span className="min-w-0 inline-flex items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground shrink-0">{capitalizeRole(role)}</span>
|
||||
<span className="min-w-0 truncate typography-micro text-muted-foreground">{message.info.id}</span>
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground shrink-0">{formatMessageDateMeta(messageCreatedAt)}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-[var(--surface-subtle)] p-0">
|
||||
<div className="group relative max-h-[26rem] w-full overflow-auto bg-[var(--surface-background)]">
|
||||
<div className="absolute top-1 right-2 z-10 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-interactive-hover/60 hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleCopyRawMessage(message.info.id, jsonValue);
|
||||
}}
|
||||
aria-label={isCopied ? 'Copied' : 'Copy JSON'}
|
||||
title={isCopied ? 'Copied' : 'Copy'}
|
||||
>
|
||||
{isCopied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language="json"
|
||||
style={syntaxTheme}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '0.75rem',
|
||||
background: 'transparent',
|
||||
fontSize: 'var(--text-micro)',
|
||||
lineHeight: '1.35',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
},
|
||||
}}
|
||||
wrapLongLines
|
||||
>
|
||||
{jsonValue}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
<OpenInAppButton directory={openDirectory} className="mr-1" />
|
||||
|
||||
@@ -1290,11 +1290,21 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
|
||||
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<SessionSidebarProps> = ({
|
||||
.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<SessionSidebarProps> = ({
|
||||
|
||||
return groups;
|
||||
},
|
||||
[homeDirectory, worktreeMetadata, pinnedSessionIds]
|
||||
[homeDirectory, worktreeMetadata, pinnedSessionIds, gitDirectories]
|
||||
);
|
||||
|
||||
const toggleGroupSessionLimit = React.useCallback((groupId: string) => {
|
||||
|
||||
@@ -16,6 +16,8 @@ interface ContextUsageDisplayProps {
|
||||
className?: string;
|
||||
valueClassName?: string;
|
||||
percentIconClassName?: string;
|
||||
onClick?: () => void;
|
||||
pressed?: boolean;
|
||||
}
|
||||
|
||||
export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
||||
@@ -30,6 +32,8 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
||||
className,
|
||||
valueClassName,
|
||||
percentIconClassName,
|
||||
onClick,
|
||||
pressed = false,
|
||||
}) => {
|
||||
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState(false);
|
||||
|
||||
@@ -56,16 +60,10 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
||||
`Output limit: ${formatTokens(safeOutputLimit)}`,
|
||||
];
|
||||
|
||||
const contextElement = (
|
||||
<div
|
||||
className={cn(
|
||||
'app-region-no-drag flex items-center gap-1.5 text-muted-foreground/60 select-none',
|
||||
size === 'compact' ? 'typography-micro' : 'typography-meta',
|
||||
className,
|
||||
)}
|
||||
aria-label="Context usage"
|
||||
onClick={isMobile ? () => setMobileTooltipOpen(true) : undefined}
|
||||
>
|
||||
const isInteractive = !isMobile && typeof onClick === 'function';
|
||||
|
||||
const contextContent = (
|
||||
<>
|
||||
{!isMobile && !hideIcon && <RiDonutChartLine className="h-4 w-4 flex-shrink-0" />}
|
||||
<span className={cn('font-medium inline-flex items-center gap-1.5', valueClassName)}>
|
||||
{showPercentIcon ? (
|
||||
@@ -82,6 +80,39 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
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 ? (
|
||||
<button
|
||||
type="button"
|
||||
className={sharedClassName}
|
||||
aria-label="Context usage"
|
||||
aria-pressed={pressed}
|
||||
onClick={onClick}
|
||||
>
|
||||
{contextContent}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className={sharedClassName}
|
||||
aria-label="Context usage"
|
||||
onClick={isMobile ? () => setMobileTooltipOpen(true) : undefined}
|
||||
>
|
||||
{contextContent}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -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<ErrorBoundaryProps, ErrorBoun
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
this.setState({ error, errorInfo });
|
||||
this.setState({ error, errorInfo, copied: false });
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
}
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
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.Component<ErrorBoundaryProps, ErrorBoun
|
||||
<summary className="cursor-pointer hover:bg-interactive-hover/80">Error details</summary>
|
||||
<pre className="mt-2 overflow-x-auto">
|
||||
{this.state.error.toString()}
|
||||
{this.state.errorInfo?.componentStack ? `\n\nComponent stack:${this.state.errorInfo.componentStack}` : ''}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
@@ -70,6 +87,9 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
|
||||
<RiRestartLine className="h-4 w-4 mr-2" />
|
||||
Try again
|
||||
</Button>
|
||||
<Button onClick={this.handleCopy} variant="outline" className="flex-1">
|
||||
{this.state.copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown>) : props;
|
||||
const messageExt = message as Record<string, unknown>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -487,3 +487,18 @@ export const fetchDesktopInstalledApps = async (
|
||||
return { apps: [], success: false, hasCache: false, isCacheStale: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const clearDesktopCache = async (): Promise<boolean> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<UIStore>()(
|
||||
|
||||
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<UIStore>()(
|
||||
|
||||
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<UIStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
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<UIStore>()(
|
||||
},
|
||||
|
||||
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 {
|
||||
|
||||
+15
-1
@@ -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<string, unknown>;
|
||||
mermaid?: Record<string, unknown>;
|
||||
components?: {
|
||||
[key: string]: ComponentType<{ children?: ReactNode; className?: string; [key: string]: unknown }>;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -430,7 +430,7 @@ const deriveSessionActivity = (payload: Record<string, unknown> | null): Session
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'message.part.updated') {
|
||||
if (type === 'message.part.updated' || type === 'message.part.delta') {
|
||||
const info = properties?.info as Record<string, unknown> | undefined;
|
||||
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
|
||||
const role = info?.role;
|
||||
|
||||
@@ -462,7 +462,7 @@ const deriveSessionActivity = (payload: Record<string, unknown> | null): Session
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'message.part.updated') {
|
||||
if (type === 'message.part.updated' || type === 'message.part.delta') {
|
||||
const info = properties?.info as Record<string, unknown> | undefined;
|
||||
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
|
||||
const role = info?.role;
|
||||
|
||||
@@ -82,7 +82,7 @@ const deriveSessionActivity = (payload: Record<string, unknown>): 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<string, unknown> | undefined;
|
||||
const sessionId = (info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId) as string;
|
||||
const role = info?.role as string;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user