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:
Bohdan Triapitsyn
2026-02-17 18:25:03 +02:00
committed by GitHub
parent 138772e66e
commit 4d71bb27eb
31 changed files with 1391 additions and 182 deletions
+22 -58
View File
@@ -30,7 +30,7 @@
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.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-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -95,7 +95,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@openchamber/desktop", "name": "@openchamber/desktop",
"version": "1.6.8", "version": "1.6.9",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/node": "^24.3.1", "@types/node": "^24.3.1",
@@ -104,7 +104,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@openchamber/ui", "name": "@openchamber/ui",
"version": "1.6.8", "version": "1.6.9",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.0", "@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.1", "@codemirror/commands": "^6.10.1",
@@ -132,7 +132,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.1.65", "@opencode-ai/sdk": "^1.2.5",
"@pierre/diffs": "1.1.0-beta.13", "@pierre/diffs": "1.1.0-beta.13",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -145,6 +145,8 @@
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0", "@remixicon/react": "^4.7.0",
"@streamdown/code": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -162,7 +164,7 @@
"react-syntax-highlighter": "^15.6.6", "react-syntax-highlighter": "^15.6.6",
"simple-git": "^3.28.0", "simple-git": "^3.28.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"streamdown": "^1.6.10", "streamdown": "^2.2.0",
"strip-json-comments": "^5.0.3", "strip-json-comments": "^5.0.3",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"yaml": "^2.8.1", "yaml": "^2.8.1",
@@ -198,10 +200,10 @@
}, },
"packages/vscode": { "packages/vscode": {
"name": "openchamber", "name": "openchamber",
"version": "1.6.8", "version": "1.6.9",
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.1.65", "@opencode-ai/sdk": "^1.2.5",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"react": "^19.1.1", "react": "^19.1.1",
@@ -221,7 +223,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@openchamber/web", "name": "@openchamber/web",
"version": "1.6.8", "version": "1.6.9",
"bin": { "bin": {
"openchamber": "./bin/cli.js", "openchamber": "./bin/cli.js",
}, },
@@ -230,7 +232,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@octokit/rest": "^22.0.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-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -843,7 +845,7 @@
"@openchamber/web": ["@openchamber/web@workspace:packages/web"], "@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=="], "@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=="], "@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=="], "@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=="], "@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/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/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=="], "@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=="], "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-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-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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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=="], "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-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-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=="], "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-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": ["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=="], "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-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-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=="], "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-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-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=="], "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-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-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-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=="], "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=="], "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=="], "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=="], "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=="], "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-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-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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+1 -1
View File
@@ -83,7 +83,7 @@
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.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-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
+45
View File
@@ -117,6 +117,8 @@ const MENU_ITEM_REPORT_BUG_ID: &str = "menu_report_bug";
const MENU_ITEM_REQUEST_FEATURE_ID: &str = "menu_request_feature"; const MENU_ITEM_REQUEST_FEATURE_ID: &str = "menu_request_feature";
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
const MENU_ITEM_JOIN_DISCORD_ID: &str = "menu_join_discord"; 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")] #[cfg(target_os = "macos")]
const GITHUB_BUG_REPORT_URL: &str = const GITHUB_BUG_REPORT_URL: &str =
@@ -268,6 +270,9 @@ fn build_macos_menu<R: tauri::Runtime>(
let join_discord = let join_discord =
MenuItem::with_id(app, MENU_ITEM_JOIN_DISCORD_ID, "Join Discord", true, None::<&str>)?; 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 = let theme_submenu =
Submenu::with_items(app, "Theme", true, &[&theme_light, &theme_dark, &theme_system])?; 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, &help_dialog,
&download_logs, &download_logs,
&PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::separator(app)?,
&clear_cache,
&PredefinedMenuItem::separator(app)?,
&report_bug, &report_bug,
&request_feature, &request_feature,
&PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::separator(app)?,
@@ -412,6 +419,35 @@ fn desktop_set_auto_worktree_menu(app: tauri::AppHandle, enabled: bool) -> Resul
Ok(()) 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] #[tauri::command]
fn desktop_open_path(path: String, app: Option<String>) -> Result<(), String> { fn desktop_open_path(path: String, app: Option<String>) -> Result<(), String> {
let trimmed = path.trim(); let trimmed = path.trim();
@@ -2455,6 +2491,14 @@ fn main() {
} }
if id == MENU_ITEM_DOWNLOAD_LOGS_ID { if id == MENU_ITEM_DOWNLOAD_LOGS_ID {
dispatch_menu_action(app, "download-logs"); 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,
desktop_new_window_at_url, desktop_new_window_at_url,
desktop_set_auto_worktree_menu, desktop_set_auto_worktree_menu,
desktop_clear_cache,
desktop_open_path, desktop_open_path,
desktop_filter_installed_apps, desktop_filter_installed_apps,
desktop_get_installed_apps, desktop_get_installed_apps,
+4 -2
View File
@@ -37,7 +37,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.1.65", "@opencode-ai/sdk": "^1.2.5",
"@pierre/diffs": "1.1.0-beta.13", "@pierre/diffs": "1.1.0-beta.13",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@@ -50,6 +50,8 @@
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@remixicon/react": "^4.7.0", "@remixicon/react": "^4.7.0",
"@streamdown/code": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -67,7 +69,7 @@
"react-syntax-highlighter": "^15.6.6", "react-syntax-highlighter": "^15.6.6",
"simple-git": "^3.28.0", "simple-git": "^3.28.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"streamdown": "^1.6.10", "streamdown": "^2.2.0",
"strip-json-comments": "^5.0.3", "strip-json-comments": "^5.0.3",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"yaml": "^2.8.1", "yaml": "^2.8.1",
@@ -341,10 +341,10 @@ export const ChatContainer: React.FC = () => {
if (!shouldSkipScroll) { if (!shouldSkipScroll) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
scrollToBottom(); scrollToBottom({ instant: true });
} else { } else {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
scrollToBottom(); scrollToBottom({ instant: true });
}); });
} }
} }
@@ -372,7 +372,7 @@ export const ChatContainer: React.FC = () => {
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined} style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
> >
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<ChatEmptyState /> <ChatEmptyState showDraftContext />
</div> </div>
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10"> <div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
<ChatInput scrollToBottom={scrollToBottom} /> <ChatInput scrollToBottom={scrollToBottom} />
@@ -1,8 +1,12 @@
import React from 'react'; import React from 'react';
import { RiGitBranchLine } from '@remixicon/react';
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { TextLoop } from '@/components/ui/TextLoop'; import { TextLoop } from '@/components/ui/TextLoop';
import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useGitStatus, useGitStore } from '@/stores/useGitStore';
const phrases = [ const phrases = [
"Fix the failing tests", "Fix the failing tests",
@@ -23,15 +27,51 @@ const phrases = [
"Add type definitions", "Add type definitions",
]; ];
const ChatEmptyState: React.FC = () => { interface ChatEmptyStateProps {
showDraftContext?: boolean;
}
const ChatEmptyState: React.FC<ChatEmptyStateProps> = ({
showDraftContext = false,
}) => {
const { currentTheme } = useThemeSystem(); 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 // Use theme's muted foreground for secondary text
const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)'; 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 ( return (
<div className="flex flex-col items-center justify-center min-h-full w-full gap-6"> <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 /> <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 <TextLoop
className="text-body-md" className="text-body-md"
interval={4} interval={4}
@@ -1,9 +1,13 @@
import React from 'react'; import React from 'react';
import { Streamdown } from 'streamdown'; import { Streamdown } from 'streamdown';
import { code } from '@streamdown/code';
import { mermaid } from '@streamdown/mermaid';
import 'streamdown/styles.css';
import { FadeInOnReveal } from './message/FadeInOnReveal'; import { FadeInOnReveal } from './message/FadeInOnReveal';
import type { Part } from '@opencode-ai/sdk/v2'; import type { Part } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react'; import { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react';
import { toast } from '@/components/ui';
import { isVSCodeRuntime } from '@/lib/desktop'; import { isVSCodeRuntime } from '@/lib/desktop';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
@@ -116,6 +120,45 @@ const useMarkdownShikiThemes = (): readonly [string | object, string | object] =
return isVSCode ? themes : fallbackThemes; 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 // Table utility functions
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => { const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
const headers: string[] = []; const headers: string[] = [];
@@ -280,21 +323,22 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement |
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
const handleDownload = (format: 'csv' | 'markdown') => { const handleDownload = (format: 'csv' | 'markdown') => {
const tableEl = tableRef.current?.querySelector('table'); const tableEl = tableRef.current?.querySelector('table');
if (!tableEl) return; if (!tableEl) return;
try { try {
const data = extractTableData(tableEl); const data = extractTableData(tableEl);
const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data); const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data);
const filename = format === 'csv' ? 'table.csv' : 'table.md'; const filename = format === 'csv' ? 'table.csv' : 'table.md';
const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown'; const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown';
downloadFile(filename, content, mimeType); downloadFile(filename, content, mimeType);
setShowMenu(false); setShowMenu(false);
} catch (err) { toast.success(`Table downloaded as ${format.toUpperCase()}`);
console.error('Failed to download table:', err); } catch (err) {
} console.error('Failed to download table:', err);
}; }
};
return ( return (
<div className="relative" ref={menuRef}> <div className="relative" ref={menuRef}>
@@ -348,9 +392,35 @@ type CodeBlockWrapperProps = React.HTMLAttributes<HTMLPreElement> & {
children?: React.ReactNode; 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 CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className, style, ...props }) => {
const [copied, setCopied] = React.useState(false); const [copied, setCopied] = React.useState(false);
const codeRef = React.useRef<HTMLDivElement>(null); 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>(() => { const normalizedStyle = React.useMemo<React.CSSProperties | undefined>(() => {
if (!style) return style; if (!style) return style;
@@ -396,6 +466,11 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
return next; return next;
}, [style]); }, [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 => { const getCodeContent = (): string => {
if (!codeRef.current) return ''; if (!codeRef.current) return '';
const codeEl = codeRef.current.querySelector('code'); const codeEl = codeRef.current.querySelector('code');
@@ -422,7 +497,7 @@ const CodeBlockWrapper: React.FC<CodeBlockWrapperProps> = ({ children, className
className={cn(className)} className={cn(className)}
style={normalizedStyle} style={normalizedStyle}
> >
{children} {codeChild}
</pre> </pre>
<div className="absolute top-1 right-2 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute top-1 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
@@ -442,6 +517,88 @@ const streamdownComponents = {
table: TableWrapper, 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'; export type MarkdownVariant = 'assistant' | 'tool';
interface MarkdownRendererProps { interface MarkdownRendererProps {
@@ -464,6 +621,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
variant = 'assistant', variant = 'assistant',
}) => { }) => {
const shikiThemes = useMarkdownShikiThemes(); const shikiThemes = useMarkdownShikiThemes();
const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions();
const componentKey = React.useMemo(() => { const componentKey = React.useMemo(() => {
const signature = part?.id ? `part-${part.id}` : `message-${messageId}`; const signature = part?.id ? `part-${part.id}` : `message-${messageId}`;
return `markdown-${signature}`; return `markdown-${signature}`;
@@ -476,12 +634,15 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
const markdownContent = ( const markdownContent = (
<div className={cn('break-words', className)}> <div className={cn('break-words', className)}>
<Streamdown <Streamdown
mode={isStreaming ? 'streaming' : 'static'} key={`streamdown-${componentKey}-${mermaidRenderKey}`}
shikiTheme={shikiThemes} mode={isStreaming ? 'streaming' : 'static'}
className={streamdownClassName} shikiTheme={shikiThemes}
controls={{ code: false, table: false }} className={streamdownClassName}
components={streamdownComponents} controls={streamdownControls}
> plugins={streamdownPlugins}
mermaid={mermaidOptions}
components={streamdownComponents}
>
{content} {content}
</Streamdown> </Streamdown>
</div> </div>
@@ -504,6 +665,7 @@ export const SimpleMarkdownRenderer: React.FC<{
variant?: MarkdownVariant; variant?: MarkdownVariant;
}> = ({ content, className, variant = 'assistant' }) => { }> = ({ content, className, variant = 'assistant' }) => {
const shikiThemes = useMarkdownShikiThemes(); const shikiThemes = useMarkdownShikiThemes();
const { options: mermaidOptions, mermaidRenderKey } = useStreamdownMermaidOptions();
const streamdownClassName = variant === 'tool' const streamdownClassName = variant === 'tool'
? 'streamdown-content streamdown-tool' ? 'streamdown-content streamdown-tool'
@@ -512,10 +674,13 @@ export const SimpleMarkdownRenderer: React.FC<{
return ( return (
<div className={cn('break-words', className)}> <div className={cn('break-words', className)}>
<Streamdown <Streamdown
key={`streamdown-simple-${mermaidRenderKey}`}
mode="static" mode="static"
shikiTheme={shikiThemes} shikiTheme={shikiThemes}
className={streamdownClassName} className={streamdownClassName}
controls={{ code: false, table: false }} controls={streamdownControls}
plugins={streamdownPlugins}
mermaid={mermaidOptions}
components={streamdownComponents} components={streamdownComponents}
> >
{content} {content}
@@ -243,19 +243,25 @@ const isActivityStandaloneTool = (toolName: unknown): boolean => {
}; };
const extractFinalAssistantText = (turn: Turn): string | undefined => { 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; const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish;
if (infoFinish === 'stop') { if (infoFinish !== 'stop') continue;
const textPart = assistantMsg.parts.find(p => p.type === 'text');
if (textPart) { for (let partIndex = assistantMsg.parts.length - 1; partIndex >= 0; partIndex -= 1) {
const textContent = (textPart as { text?: string | null | undefined }).text ?? const part = assistantMsg.parts[partIndex];
(textPart as { content?: string | null | undefined }).content; if (!part || part.type !== 'text') continue;
if (typeof textContent === 'string' && textContent.trim().length > 0) {
return textContent; 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; return undefined;
}; };
@@ -102,21 +102,25 @@ export const detectTurns = (messages: ChatMessageEntry[]): Turn[] => {
}; };
const extractFinalAssistantText = (turn: Turn): string | undefined => { 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; const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish;
if (infoFinish !== 'stop') continue;
if (infoFinish === 'stop') { for (let partIndex = assistantMsg.parts.length - 1; partIndex >= 0; partIndex -= 1) {
const textPart = assistantMsg.parts.find(p => p.type === 'text'); const part = assistantMsg.parts[partIndex];
if (textPart) { if (!part || part.type !== 'text') continue;
const textContent = (textPart as { text?: string | null | undefined }).text ??
(textPart as { content?: string | null | undefined }).content; const textContent = (part as { text?: string | null | undefined }).text ??
if (typeof textContent === 'string' && textContent.trim().length > 0) { (part as { content?: string | null | undefined }).content;
return textContent; if (typeof textContent === 'string' && textContent.trim().length > 0) {
} return textContent;
} }
} }
} }
return undefined; return undefined;
}; };
@@ -16,6 +16,7 @@ import {
renderWebSearchOutput, renderWebSearchOutput,
formatInputForDisplay, formatInputForDisplay,
parseDiffToUnified, parseDiffToUnified,
parseReadToolOutput,
type UnifiedDiffHunk, type UnifiedDiffHunk,
type SideBySideDiffHunk, type SideBySideDiffHunk,
type SideBySideDiffLine, type SideBySideDiffLine,
@@ -740,33 +741,41 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
} }
if (tool === 'read') { if (tool === 'read') {
const lines = popup.content.split('\n'); const parsedReadOutput = parseReadToolOutput(popup.content);
const inputMeta = popup.metadata?.input; const inputMeta = popup.metadata?.input;
const inputObj = typeof inputMeta === 'object' && inputMeta !== null ? (inputMeta as Record<string, unknown>) : {}; const inputObj = typeof inputMeta === 'object' && inputMeta !== null ? (inputMeta as Record<string, unknown>) : {};
const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0; const offset = typeof inputObj.offset === 'number' ? inputObj.offset : 0;
const limit = typeof inputObj.limit === 'number' ? inputObj.limit : undefined; let fallbackLineCursor = offset;
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
const isInfoMessage = (line: string) => line.trim().startsWith('(');
return ( return (
<div> <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 ( 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)' }}> <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> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{isInfo ? ( {line.isInfo ? (
<div className="whitespace-pre-wrap break-words">{line}</div> <div className="whitespace-pre-wrap break-words">{line.text}</div>
) : ( ) : (
<SyntaxHighlighter <SyntaxHighlighter
style={syntaxTheme} style={syntaxTheme}
@@ -794,7 +803,7 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
}, },
}} }}
> >
{line} {line.text}
</SyntaxHighlighter> </SyntaxHighlighter>
)} )}
</div> </div>
@@ -24,6 +24,7 @@ import {
formatEditOutput, formatEditOutput,
detectLanguageFromOutput, detectLanguageFromOutput,
formatInputForDisplay, formatInputForDisplay,
parseReadToolOutput,
} from '../toolRenderers'; } from '../toolRenderers';
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } }; 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 (hasStringOutput && outputString.trim()) {
if (part.tool === 'read') { if (part.tool === 'read') {
const formattedOutput = formatEditOutput(outputString, part.tool, metadata); const parsedReadOutput = parseReadToolOutput(outputString);
const lines = formattedOutput.split('\n');
const offset = typeof input?.offset === 'number' ? input.offset : 0; const offset = typeof input?.offset === 'number' ? input.offset : 0;
const limit = typeof input?.limit === 'number' ? input.limit : undefined; const contentForLanguage = parsedReadOutput.lines.map((line) => line.text).join('\n');
const isInfoMessage = (line: string) => line.trim().startsWith('('); let fallbackLineCursor = offset;
const hasExplicitLineNumbers = parsedReadOutput.lines.some((line) => line.lineNumber !== null);
return renderScrollableBlock( return renderScrollableBlock(
<div className="typography-code w-full min-w-0 space-y-1"> <div className="typography-code w-full min-w-0 space-y-1">
{lines.map((line: string, idx: number) => { {parsedReadOutput.lines.map((line, idx) => {
const isInfo = isInfoMessage(line); if (line.lineNumber !== null) {
const lineNumber = offset + idx + 1; fallbackLineCursor = line.lineNumber;
const shouldShowLineNumber = !isInfo && (limit === undefined || idx < limit); }
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 ( 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)' }}> <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> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{isInfo ? ( {line.isInfo ? (
<div className="whitespace-pre-wrap break-words">{line}</div> <div className="whitespace-pre-wrap break-words">{line.text}</div>
) : ( ) : (
<SyntaxHighlighter <SyntaxHighlighter
style={syntaxTheme} style={syntaxTheme}
language={detectLanguageFromOutput(formattedOutput, part.tool, input as Record<string, unknown>)} language={detectLanguageFromOutput(contentForLanguage, part.tool, input as Record<string, unknown>)}
PreTag="div" PreTag="div"
wrapLines wrapLines
wrapLongLines wrapLongLines
@@ -855,7 +865,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
}, },
}} }}
> >
{line} {line.text}
</SyntaxHighlighter> </SyntaxHighlighter>
)} )}
</div> </div>
@@ -1073,6 +1083,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity); const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
const justificationText = React.useMemo(() => { const justificationText = React.useMemo(() => {
if (!showTextJustificationActivity) return null; 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" // Get title or description from state - this is the "yapping" text like "Shows system information"
const title = (stateWithData as { title?: string }).title; const title = (stateWithData as { title?: string }).title;
if (typeof title === 'string' && title.trim().length > 0) { if (typeof title === 'string' && title.trim().length > 0) {
@@ -1083,7 +1094,7 @@ const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxT
return inputDesc; return inputDesc;
} }
return null; return null;
}, [showTextJustificationActivity, stateWithData, input]); }, [showTextJustificationActivity, part.tool, stateWithData, input]);
const runtime = React.useContext(RuntimeAPIContext); const runtime = React.useContext(RuntimeAPIContext);
@@ -44,6 +44,81 @@ export const formatEditOutput = (output: string, toolName: string, metadata?: Re
return cleaned; 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 }) => { export const renderListOutput = (output: string, options?: { unstyled?: boolean }) => {
try { try {
const lines = output.trim().split('\n').filter(Boolean); const lines = output.trim().split('\n').filter(Boolean);
@@ -7,6 +7,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { ContextPanelContent } from './ContextSidebarTab';
const CONTEXT_PANEL_MIN_WIDTH = 360; const CONTEXT_PANEL_MIN_WIDTH = 360;
const CONTEXT_PANEL_MAX_WIDTH = 1400; 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 activeFilePath = useFilesViewTabsStore((state) => (directoryKey ? (state.byRoot[directoryKey]?.selectedPath ?? null) : null));
const panelTitle = panelState?.mode === 'diff' ? 'Diff' : panelState?.mode === 'file' ? 'File' : 'Panel'; 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?.targetPath ?? null); const effectivePath = panelState?.mode === 'file' ? (activeFilePath ?? panelState?.targetPath ?? null) : panelState?.mode === 'context' ? null : (panelState?.targetPath ?? null);
const pathLabel = getRelativePathLabel(effectivePath, effectiveDirectory); const pathLabel = getRelativePathLabel(effectivePath, effectiveDirectory);
const content = panelState?.mode === 'diff' const content = panelState?.mode === 'diff'
? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate /> ? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate />
: panelState?.mode === 'file' : panelState?.mode === 'file'
? <FilesView mode="editor-only" /> ? <FilesView mode="editor-only" />
: panelState?.mode === 'context'
? <ContextPanelContent />
: null; : null;
const header = ( 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>&middot;</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>
);
};
+37 -2
View File
@@ -141,6 +141,9 @@ export const Header: React.FC = () => {
const toggleSidebar = useUIStore((state) => state.toggleSidebar); const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const toggleBottomTerminal = useUIStore((state) => state.toggleBottomTerminal); const toggleBottomTerminal = useUIStore((state) => state.toggleBottomTerminal);
const toggleRightSidebar = useUIStore((state) => state.toggleRightSidebar); 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 setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const activeMainTab = useUIStore((state) => state.activeMainTab); const activeMainTab = useUIStore((state) => state.activeMainTab);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
@@ -338,9 +341,15 @@ export const Header: React.FC = () => {
const updateProjectTabsOverflow = React.useCallback(() => { const updateProjectTabsOverflow = React.useCallback(() => {
const el = projectTabsScrollRef.current; const el = projectTabsScrollRef.current;
if (!el) return; if (!el) return;
setProjectTabsOverflow({ const next = {
left: el.scrollLeft > 2, left: el.scrollLeft > 2,
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 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); setSettingsDialogOpen(true);
}, [blurActiveElement, isMobile, setSessionSwitcherOpen, setSettingsDialogOpen]); }, [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 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(() => { const desktopPaddingClass = React.useMemo(() => {
@@ -1604,9 +1637,11 @@ export const Header: React.FC = () => {
size="compact" size="compact"
hideIcon hideIcon
showPercentIcon showPercentIcon
onClick={handleOpenContextPanel}
pressed={isContextPanelActive}
className="mr-3.5" className="mr-3.5"
valueClassName="typography-ui-label font-medium leading-none text-foreground" 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" /> <OpenInAppButton directory={openDirectory} className="mr-1" />
@@ -1290,11 +1290,21 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
sortedWorktrees.forEach((meta) => { sortedWorktrees.forEach((meta) => {
const directory = normalizePath(meta.path) ?? meta.path; 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({ groups.push({
id: `worktree:${directory}`, id: `worktree:${directory}`,
label, label,
branch: meta.branch || null, branch: currentBranch || metadataBranch,
description: formatPathForDisplay(directory, homeDirectory), description: formatPathForDisplay(directory, homeDirectory),
isMain: false, isMain: false,
worktree: meta, worktree: meta,
@@ -1309,10 +1319,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
.sort((a, b) => (groupOrder.get(a) ?? 0) - (groupOrder.get(b) ?? 0)); .sort((a, b) => (groupOrder.get(a) ?? 0) - (groupOrder.get(b) ?? 0));
orphanKeys.forEach((directory) => { orphanKeys.forEach((directory) => {
const currentBranch = gitDirectories.get(directory)?.status?.current?.trim() || null;
groups.push({ groups.push({
id: `worktree:orphan:${directory}`, id: `worktree:orphan:${directory}`,
label: formatDirectoryName(directory, homeDirectory) || directory, label: formatDirectoryName(directory, homeDirectory) || directory,
branch: null, branch: currentBranch,
description: formatPathForDisplay(directory, homeDirectory), description: formatPathForDisplay(directory, homeDirectory),
isMain: false, isMain: false,
worktree: null, worktree: null,
@@ -1323,7 +1334,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return groups; return groups;
}, },
[homeDirectory, worktreeMetadata, pinnedSessionIds] [homeDirectory, worktreeMetadata, pinnedSessionIds, gitDirectories]
); );
const toggleGroupSessionLimit = React.useCallback((groupId: string) => { const toggleGroupSessionLimit = React.useCallback((groupId: string) => {
@@ -16,6 +16,8 @@ interface ContextUsageDisplayProps {
className?: string; className?: string;
valueClassName?: string; valueClassName?: string;
percentIconClassName?: string; percentIconClassName?: string;
onClick?: () => void;
pressed?: boolean;
} }
export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
@@ -30,6 +32,8 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
className, className,
valueClassName, valueClassName,
percentIconClassName, percentIconClassName,
onClick,
pressed = false,
}) => { }) => {
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState(false); const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState(false);
@@ -56,16 +60,10 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
`Output limit: ${formatTokens(safeOutputLimit)}`, `Output limit: ${formatTokens(safeOutputLimit)}`,
]; ];
const contextElement = ( const isInteractive = !isMobile && typeof onClick === 'function';
<div
className={cn( const contextContent = (
'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}
>
{!isMobile && !hideIcon && <RiDonutChartLine className="h-4 w-4 flex-shrink-0" />} {!isMobile && !hideIcon && <RiDonutChartLine className="h-4 w-4 flex-shrink-0" />}
<span className={cn('font-medium inline-flex items-center gap-1.5', valueClassName)}> <span className={cn('font-medium inline-flex items-center gap-1.5', valueClassName)}>
{showPercentIcon ? ( {showPercentIcon ? (
@@ -82,6 +80,39 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
</> </>
)} )}
</span> </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> </div>
); );
@@ -7,6 +7,7 @@ interface ErrorBoundaryState {
hasError: boolean; hasError: boolean;
error?: Error; error?: Error;
errorInfo?: React.ErrorInfo; errorInfo?: React.ErrorInfo;
copied?: boolean;
} }
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
@@ -25,17 +26,32 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
} }
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { 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 = () => { handleReset = () => {
this.setState({ hasError: false, error: undefined, errorInfo: undefined }); 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() { render() {
if (this.state.hasError) { if (this.state.hasError) {
if (this.props.fallback) { 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> <summary className="cursor-pointer hover:bg-interactive-hover/80">Error details</summary>
<pre className="mt-2 overflow-x-auto"> <pre className="mt-2 overflow-x-auto">
{this.state.error.toString()} {this.state.error.toString()}
{this.state.errorInfo?.componentStack ? `\n\nComponent stack:${this.state.errorInfo.componentStack}` : ''}
</pre> </pre>
</details> </details>
)} )}
@@ -70,6 +87,9 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
<RiRestartLine className="h-4 w-4 mr-2" /> <RiRestartLine className="h-4 w-4 mr-2" />
Try again Try again
</Button> </Button>
<Button onClick={this.handleCopy} variant="outline" className="flex-1">
{this.state.copied ? 'Copied' : 'Copy'}
</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+23 -14
View File
@@ -66,6 +66,7 @@ const PIN_THRESHOLD_RATIO = 0.10;
export const useChatScrollManager = ({ export const useChatScrollManager = ({
currentSessionId, currentSessionId,
sessionMessages, sessionMessages,
streamingMessageId,
updateViewportAnchor, updateViewportAnchor,
isSyncing, isSyncing,
isMobile, isMobile,
@@ -117,6 +118,15 @@ export const useChatScrollManager = ({
scrollEngine.scrollToPosition(Math.max(0, bottom), options); scrollEngine.scrollToPosition(Math.max(0, bottom), options);
}, [markProgrammaticScroll, scrollEngine]); }, [markProgrammaticScroll, scrollEngine]);
const scrollPinnedToBottom = React.useCallback(() => {
if (streamingMessageId) {
scrollToBottomInternal({ followBottom: true });
return;
}
scrollToBottomInternal({ instant: true });
}, [scrollToBottomInternal, streamingMessageId]);
const updateScrollButtonVisibility = React.useCallback(() => { const updateScrollButtonVisibility = React.useCallback(() => {
const container = scrollRef.current; const container = scrollRef.current;
if (!container) { if (!container) {
@@ -250,13 +260,12 @@ export const useChatScrollManager = ({
const container = scrollRef.current; const container = scrollRef.current;
if (!container) return; 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(); const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) { if (distanceFromBottom > getPinThreshold()) {
markProgrammaticScroll(); scrollPinnedToBottom();
scrollToBottomInternal({ instant: true });
} }
}, [getDistanceFromBottom, getPinThreshold, isSyncing, markProgrammaticScroll, scrollToBottomInternal, sessionMessages]); }, [getDistanceFromBottom, getPinThreshold, isSyncing, scrollPinnedToBottom, sessionMessages]);
// Use ResizeObserver to detect content changes and maintain pin // Use ResizeObserver to detect content changes and maintain pin
React.useEffect(() => { React.useEffect(() => {
@@ -266,11 +275,11 @@ export const useChatScrollManager = ({
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
updateScrollButtonVisibility(); updateScrollButtonVisibility();
// Maintain pin when content grows - always instant for smooth experience // Maintain pin when content grows - fast smooth follow
if (isPinnedRef.current) { if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom(); const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) { if (distanceFromBottom > getPinThreshold()) {
scrollToBottomInternal({ instant: true }); scrollPinnedToBottom();
} }
} }
}); });
@@ -282,7 +291,7 @@ export const useChatScrollManager = ({
if (isPinnedRef.current) { if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom(); const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) { if (distanceFromBottom > getPinThreshold()) {
scrollToBottomInternal({ instant: true }); scrollPinnedToBottom();
} }
} }
}); });
@@ -293,7 +302,7 @@ export const useChatScrollManager = ({
observer.disconnect(); observer.disconnect();
childObserver.disconnect(); childObserver.disconnect();
}; };
}, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]); }, [getDistanceFromBottom, getPinThreshold, scrollPinnedToBottom, updateScrollButtonVisibility]);
React.useEffect(() => { React.useEffect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -315,14 +324,14 @@ export const useChatScrollManager = ({
const handleMessageContentChange = React.useCallback(() => { const handleMessageContentChange = React.useCallback(() => {
updateScrollButtonVisibility(); updateScrollButtonVisibility();
// Maintain pin when content changes - always instant // Maintain pin when content changes - fast smooth follow
if (isPinnedRef.current) { if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom(); const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) { if (distanceFromBottom > getPinThreshold()) {
scrollToBottomInternal({ instant: true }); scrollPinnedToBottom();
} }
} }
}, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]); }, [getDistanceFromBottom, getPinThreshold, scrollPinnedToBottom, updateScrollButtonVisibility]);
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => { const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
const existing = animationHandlersRef.current.get(messageId); const existing = animationHandlersRef.current.get(messageId);
@@ -336,7 +345,7 @@ export const useChatScrollManager = ({
if (isPinnedRef.current) { if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom(); const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) { if (distanceFromBottom > getPinThreshold()) {
scrollToBottomInternal({ instant: true }); scrollPinnedToBottom();
} }
} }
}, },
@@ -350,7 +359,7 @@ export const useChatScrollManager = ({
if (isPinnedRef.current) { if (isPinnedRef.current) {
const distanceFromBottom = getDistanceFromBottom(); const distanceFromBottom = getDistanceFromBottom();
if (distanceFromBottom > getPinThreshold()) { if (distanceFromBottom > getPinThreshold()) {
scrollToBottomInternal({ instant: true }); scrollPinnedToBottom();
} }
} }
}, },
@@ -360,7 +369,7 @@ export const useChatScrollManager = ({
animationHandlersRef.current.set(messageId, handlers); animationHandlersRef.current.set(messageId, handlers);
return handlers; return handlers;
}, [getDistanceFromBottom, getPinThreshold, scrollToBottomInternal, updateScrollButtonVisibility]); }, [getDistanceFromBottom, getPinThreshold, scrollPinnedToBottom, updateScrollButtonVisibility]);
return { return {
scrollRef, scrollRef,
+75 -1
View File
@@ -818,7 +818,6 @@ export const useEventStream = () => {
type: part.type || 'text', type: part.type || 'text',
} as Part; } as Part;
// Fallback: if we see assistant parts but session.status hasn't arrived yet, mark busy.
if (roleInfo === 'assistant') { if (roleInfo === 'assistant') {
const partType = (messagePart as { type?: unknown }).type; const partType = (messagePart as { type?: unknown }).type;
const partTime = (messagePart as { time?: { end?: unknown } }).time; const partTime = (messagePart as { time?: { end?: unknown } }).time;
@@ -863,6 +862,81 @@ export const useEventStream = () => {
break; 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': { case 'message.updated': {
const message = (typeof props.info === 'object' && props.info !== null) ? (props.info as Record<string, unknown>) : props; const message = (typeof props.info === 'object' && props.info !== null) ? (props.info as Record<string, unknown>) : props;
const messageExt = message as Record<string, unknown>; const messageExt = message as Record<string, unknown>;
+5
View File
@@ -864,6 +864,11 @@ html:not(.dark) .chat-scroll {
contain-intrinsic-size: none !important; 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 */ /* Hide system caret in terminal - targets both Ghostty internal input and our custom touch input */
.terminal-viewport-container textarea, .terminal-viewport-container textarea,
.terminal-viewport-container input { .terminal-viewport-container input {
+15
View File
@@ -487,3 +487,18 @@ export const fetchDesktopInstalledApps = async (
return { apps: [], success: false, hasCache: false, isCacheStale: false }; 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;
}
};
+68 -2
View File
@@ -6,7 +6,7 @@ import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files'; export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files';
export type RightSidebarTab = 'git' | 'files'; export type RightSidebarTab = 'git' | 'files';
export type ContextPanelMode = 'diff' | 'file'; export type ContextPanelMode = 'diff' | 'file' | 'context';
type ContextPanelDirectoryState = { type ContextPanelDirectoryState = {
isOpen: boolean; isOpen: boolean;
@@ -224,6 +224,7 @@ interface UIStore {
setRightSidebarTab: (tab: RightSidebarTab) => void; setRightSidebarTab: (tab: RightSidebarTab) => void;
openContextDiff: (directory: string, filePath: string) => void; openContextDiff: (directory: string, filePath: string) => void;
openContextFile: (directory: string, filePath: string) => void; openContextFile: (directory: string, filePath: string) => void;
openContextOverview: (directory: string) => void;
closeContextPanel: (directory: string) => void; closeContextPanel: (directory: string) => void;
toggleContextPanelExpanded: (directory: string) => void; toggleContextPanelExpanded: (directory: string) => void;
setContextPanelWidth: (directory: string, width: number) => void; setContextPanelWidth: (directory: string, width: number) => void;
@@ -404,6 +405,18 @@ export const useUIStore = create<UIStore>()(
setSidebarOpen: (open) => { setSidebarOpen: (open) => {
set((state) => { 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) { if (open && !state.hasManuallyResizedLeftSidebar) {
return { return {
isSidebarOpen: open, isSidebarOpen: open,
@@ -434,6 +447,18 @@ export const useUIStore = create<UIStore>()(
setRightSidebarOpen: (open) => { setRightSidebarOpen: (open) => {
set((state) => { 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) { if (open && !state.hasManuallyResizedRightSidebar) {
return { return {
isRightSidebarOpen: open, 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) => { closeContextPanel: (directory) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim()); const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
if (!normalizedDirectory) { if (!normalizedDirectory) {
@@ -585,7 +633,25 @@ export const useUIStore = create<UIStore>()(
}, },
setBottomTerminalOpen: (open) => { 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') { if (open && typeof window !== 'undefined') {
const proportionalHeight = Math.floor(window.innerHeight * 0.32); const proportionalHeight = Math.floor(window.innerHeight * 0.32);
return { return {
+15 -1
View File
@@ -4,6 +4,13 @@ declare module 'streamdown' {
export interface StreamdownProps { export interface StreamdownProps {
children: string; children: string;
mode?: 'streaming' | 'static'; mode?: 'streaming' | 'static';
isAnimating?: boolean;
animated?: {
animation?: string;
duration?: number;
easing?: string;
sep?: 'word' | 'char';
};
className?: string; className?: string;
shikiTheme?: readonly [string | object, string | object]; shikiTheme?: readonly [string | object, string | object];
@@ -11,8 +18,15 @@ declare module 'streamdown' {
controls?: boolean | { controls?: boolean | {
code?: boolean; code?: boolean;
table?: boolean; table?: boolean;
mermaid?: boolean; mermaid?: boolean | {
download?: boolean;
copy?: boolean;
fullscreen?: boolean;
panZoom?: boolean;
};
}; };
plugins?: Record<string, unknown>;
mermaid?: Record<string, unknown>;
components?: { components?: {
[key: string]: ComponentType<{ children?: ReactNode; className?: string; [key: string]: unknown }>; [key: string]: ComponentType<{ children?: ReactNode; className?: string; [key: string]: unknown }>;
}; };
+1 -1
View File
@@ -229,7 +229,7 @@
}, },
"dependencies": { "dependencies": {
"@openchamber/ui": "workspace:*", "@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.1.65", "@opencode-ai/sdk": "^1.2.5",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
"react": "^19.1.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 info = properties?.info as Record<string, unknown> | undefined;
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId; const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
const role = info?.role; const role = info?.role;
+1 -1
View File
@@ -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 info = properties?.info as Record<string, unknown> | undefined;
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId; const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
const role = info?.role; 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 info = properties?.info as Record<string, unknown> | undefined;
const sessionId = (info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId) as string; const sessionId = (info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId) as string;
const role = info?.role as string; const role = info?.role as string;
+1 -1
View File
@@ -26,7 +26,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1", "@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@octokit/rest": "^22.0.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-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
+1 -2
View File
@@ -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 info = payload.properties?.info;
const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId; const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId;
const role = info?.role; const role = info?.role;
@@ -11177,7 +11177,6 @@ Context:
res.json({ success: true, killedCount }); res.json({ success: true, killedCount });
}); });
try { try {
syncFromHmrState(); syncFromHmrState();
if (await isOpenCodeProcessHealthy()) { if (await isOpenCodeProcessHealthy()) {