From 3d27c0a0cad022657ba3ebf1cf5cf6da75b1895f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 26 Jun 2026 11:26:11 +0000 Subject: [PATCH 001/209] perf(build): fix bun .bun chunk parsing and isolate Vite preload helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manualChunks package-name parser used the first node_modules/ segment, which under bun's isolated install (.bun/@/node_modules/) is always '.bun'. This collapsed nearly every dependency — including lazy-only heavy libs (Shiki langs/themes, transformers, katex, mermaid, elkjs, etc.) — into one 18.5 MB eager 'vendor-.bun' chunk, defeating all code splitting. - Resolve the real package from the LAST node_modules/ segment. - Pin Vite's __vitePreload helper to its own chunk so Rollup stops co-locating it inside vendor-shiki and dragging Shiki core + the 629 KB oniguruma engine into the eager bootstrap. - Drop the now-dead react-syntax-highlighter chunk rule. Eager bootstrap graph drops from ~18.5 MB to ~0.44 MB. Co-authored-by: Serhii Dziupin --- packages/web/vite.config.ts | 17 +++++++++++++++-- vite.config.ts | 17 +++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 553a892b..3fcabff7 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -109,9 +109,23 @@ export default defineConfig({ external: ['node:child_process', 'node:fs', 'node:path', 'node:url'], output: { manualChunks(id) { + // Pin Vite's tiny runtime helpers to their own stable chunk. Otherwise + // Rollup co-locates the `__vitePreload` helper into an arbitrary vendor + // chunk (e.g. `shiki`), and since every dynamic import pulls the helper, + // that whole vendor (here Shiki core + the 629KB oniguruma engine) gets + // dragged into the eager bootstrap graph. + if (id.includes('vite/preload-helper') || id.includes('vite/modulepreload-polyfill')) { + return 'vendor-vite-runtime'; + } if (!id.includes('node_modules')) return undefined; - const match = id.split('node_modules/')[1]; + // Resolve the real package from the LAST `node_modules/` segment. + // bun's isolated install nests packages as + // `node_modules/.bun/@/node_modules//...`, so the first + // `node_modules/` segment is `.bun` — using it collapses every dependency + // (incl. lazy-only ones) into a single giant eager `vendor-.bun` chunk. + const lastNodeModules = id.lastIndexOf('node_modules/'); + const match = id.slice(lastNodeModules + 'node_modules/'.length); if (!match) return undefined; const segments = match.split('/'); @@ -123,7 +137,6 @@ export default defineConfig({ if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk'; if (packageName.includes('remark') || packageName.includes('rehype') || packageName === 'react-markdown') return 'vendor-markdown'; if (packageName === '@base-ui/react' || packageName.startsWith('@base-ui')) return 'vendor-base-ui'; - if (packageName.includes('react-syntax-highlighter') || packageName.includes('highlight.js')) return 'vendor-syntax'; const sanitized = packageName.replace(/^@/, '').replace(/\//g, '-'); return `vendor-${sanitized}`; diff --git a/vite.config.ts b/vite.config.ts index 5f6cb03c..ae9721f4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -33,9 +33,23 @@ export default defineConfig({ external: ['node:child_process', 'node:fs', 'node:path', 'node:url'], output: { manualChunks(id) { + // Pin Vite's tiny runtime helpers to their own stable chunk. Otherwise + // Rollup co-locates the `__vitePreload` helper into an arbitrary vendor + // chunk (e.g. `shiki`), and since every dynamic import pulls the helper, + // that whole vendor (here Shiki core + the 629KB oniguruma engine) gets + // dragged into the eager bootstrap graph. + if (id.includes('vite/preload-helper') || id.includes('vite/modulepreload-polyfill')) { + return 'vendor-vite-runtime' + } if (!id.includes('node_modules')) return undefined - const match = id.split('node_modules/')[1] + // Resolve the real package from the LAST `node_modules/` segment. + // bun's isolated install nests packages as + // `node_modules/.bun/@/node_modules//...`, so the first + // `node_modules/` segment is `.bun` — using it collapses every dependency + // (incl. lazy-only ones) into a single giant eager `vendor-.bun` chunk. + const lastNodeModules = id.lastIndexOf('node_modules/') + const match = id.slice(lastNodeModules + 'node_modules/'.length) if (!match) return undefined const segments = match.split('/') @@ -46,7 +60,6 @@ export default defineConfig({ if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk' if (packageName.includes('remark') || packageName.includes('rehype') || packageName === 'react-markdown') return 'vendor-markdown' if (packageName === '@base-ui/react' || packageName.startsWith('@base-ui')) return 'vendor-base-ui' - if (packageName.includes('react-syntax-highlighter') || packageName.includes('highlight.js')) return 'vendor-syntax' const sanitized = packageName.replace(/^@/, '').replace(/\//g, '-') return `vendor-${sanitized}` From 05dd270a9a9f5e53170592ab72f0d24a5765d774 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 26 Jun 2026 11:26:11 +0000 Subject: [PATCH 002/209] perf(ui): lazy-load html-to-image and snapDOM Both are only needed on explicit user actions (export message as image, capture preview annotation screenshot). Defer them with dynamic import so ~140 KB leaves the eager app-shell graph. Co-authored-by: Serhii Dziupin --- packages/ui/src/components/chat/message/MessageBody.tsx | 4 +++- packages/ui/src/lib/preview/screenshot-capture.ts | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index e2e36000..4bfcf251 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -33,7 +33,6 @@ import { copyTextToClipboard } from '@/lib/clipboard'; import { useChatSurfaceMode } from '@/components/chat/useChatSurfaceMode'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { toPng } from 'html-to-image'; import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import { formatTimestampForDisplay } from './timeFormat'; @@ -1455,6 +1454,9 @@ const AssistantMessageBody = React.memo(({ wrapper.appendChild(clone); document.body.appendChild(wrapper); + // Lazy-load html-to-image: only needed when the user exports a + // message as an image, so keep it out of the eager app shell. + const { toPng } = await import('html-to-image'); const dataUrl = await toPng(wrapper, { quality: 1, pixelRatio: 2, diff --git a/packages/ui/src/lib/preview/screenshot-capture.ts b/packages/ui/src/lib/preview/screenshot-capture.ts index a587a943..28611fef 100644 --- a/packages/ui/src/lib/preview/screenshot-capture.ts +++ b/packages/ui/src/lib/preview/screenshot-capture.ts @@ -1,5 +1,3 @@ -import { snapdom } from '@zumer/snapdom'; -import { getFontEmbedCSS, toJpeg } from 'html-to-image'; import { invokeDesktop } from '@/lib/desktop'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -825,6 +823,9 @@ async function captureIframeSnapdomScreenshot( // Defensive: undo any nested-scroll drift from asset inlining before capture. nestedScroll.reapply(); + // Lazy-load snapDOM: only needed when actually capturing a preview + // annotation screenshot, so keep it out of the eager app shell. + const { snapdom } = await import('@zumer/snapdom'); const snapdomOptions = { backgroundColor: getCaptureBackgroundColor(document), cache: 'disabled' as const, @@ -924,6 +925,8 @@ async function captureIframeDomScreenshot( await document.fonts?.ready.catch(() => undefined); restoreAssets = await inlineIframeCaptureAssets(document, viewportWidth, viewportHeight, { applyLayoutWorkarounds: true }); frameWindow.scrollTo(scrollX, scrollY); + // Lazy-load html-to-image: only the fallback DOM-capture path needs it. + const { getFontEmbedCSS, toJpeg } = await import('html-to-image'); const fontEmbedCSS = await getFontEmbedCSS(root).catch(() => ''); dataUrl = await toJpeg(root, { From f1721199215ad7a3b27e92d8e6efffb9f497a4fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 26 Jun 2026 11:26:11 +0000 Subject: [PATCH 003/209] chore(deps): remove unused react-syntax-highlighter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-syntax-highlighter (and its @types) is no longer imported anywhere — code highlighting is handled by the Shiki web worker. Drop the dead deps. Co-authored-by: Serhii Dziupin --- bun.lock | 94 +++++++++------------------------------------------- package.json | 2 -- 2 files changed, 16 insertions(+), 80 deletions(-) diff --git a/bun.lock b/bun.lock index 506dcb99..77837bf1 100644 --- a/bun.lock +++ b/bun.lock @@ -43,7 +43,6 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", - "@types/react-syntax-highlighter": "^15.5.13", "@xenova/transformers": "^2.17.2", "@zumer/snapdom": "^2.12.8", "bun-pty": "^0.4.5", @@ -58,7 +57,6 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^15.6.6", "remark-gfm": "^4.0.1", "simple-git": "^3.28.0", "sonner": "^2.0.7", @@ -99,7 +97,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.13.2", + "version": "1.13.3", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -114,7 +112,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.13.2", + "version": "1.13.3", "dependencies": { "@base-ui/react": "^1.4.0", "@codemirror/autocomplete": "^6.20.0", @@ -214,7 +212,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.13.2", + "version": "1.13.3", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.17.9", @@ -237,7 +235,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.13.2", + "version": "1.13.3", "bin": { "openchamber": "./bin/cli.js", }, @@ -1321,8 +1319,6 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="], - "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], @@ -1585,13 +1581,13 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "character-entities": ["character-entities@1.2.4", "", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], - "character-entities-legacy": ["character-entities-legacy@1.1.4", "", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="], + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - "character-reference-invalid": ["character-reference-invalid@1.1.4", "", {}, "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg=="], + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], @@ -1925,8 +1921,6 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], - "fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="], - "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -1961,8 +1955,6 @@ "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], - "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], - "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="], @@ -2063,7 +2055,7 @@ "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@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], @@ -2073,14 +2065,10 @@ "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@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=="], "heic2any": ["heic2any@0.0.4", "", {}, "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA=="], - "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], - - "highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="], - "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], "html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="], @@ -2147,9 +2135,9 @@ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - "is-alphabetical": ["is-alphabetical@1.0.4", "", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], - "is-alphanumerical": ["is-alphanumerical@1.0.4", "", { "dependencies": { "is-alphabetical": "^1.0.0", "is-decimal": "^1.0.0" } }, "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A=="], + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], @@ -2171,7 +2159,7 @@ "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], - "is-decimal": ["is-decimal@1.0.4", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="], + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], @@ -2185,7 +2173,7 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-hexadecimal": ["is-hexadecimal@1.0.4", "", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], @@ -2369,8 +2357,6 @@ "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], - "lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="], - "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], @@ -2635,7 +2621,7 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - "parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="], + "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=="], "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], @@ -2697,8 +2683,6 @@ "pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="], - "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], - "proc-log": ["proc-log@2.0.1", "", {}, "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], @@ -2765,8 +2749,6 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - "react-syntax-highlighter": ["react-syntax-highlighter@15.6.6", "", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw=="], - "read": ["read@1.0.7", "", { "dependencies": { "mute-stream": "~0.0.4" } }, "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ=="], "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], @@ -2781,8 +2763,6 @@ "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], - "refractor": ["refractor@3.6.0", "", { "dependencies": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", "prismjs": "~1.27.0" } }, "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA=="], - "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], @@ -3305,8 +3285,6 @@ "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], @@ -3487,8 +3465,6 @@ "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], - "dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -3521,18 +3497,6 @@ "globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], - "hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], - - "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], - - "hastscript/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="], - - "hastscript/comma-separated-tokens": ["comma-separated-tokens@1.0.8", "", {}, "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw=="], - - "hastscript/property-information": ["property-information@5.6.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA=="], - - "hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="], - "iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], @@ -3553,8 +3517,6 @@ "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "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=="], - "micromark-extension-math/katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -3591,6 +3553,8 @@ "openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "parse-semver/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], @@ -3611,16 +3575,12 @@ "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], - "react-syntax-highlighter/@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], - "read-pkg/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="], - "rehype-katex/katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -3643,8 +3603,6 @@ "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - "superagent/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], "supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -3743,30 +3701,12 @@ "glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], - "hast-util-from-dom/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], - - "hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], - - "hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], "make-fetch-happen/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "make-fetch-happen/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "mdast-util-mdx-jsx/parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - - "mdast-util-mdx-jsx/parse-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - - "mdast-util-mdx-jsx/parse-entities/character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - - "mdast-util-mdx-jsx/parse-entities/is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], - - "mdast-util-mdx-jsx/parse-entities/is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], - - "mdast-util-mdx-jsx/parse-entities/is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], - "node-gyp/make-fetch-happen/cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], "node-gyp/make-fetch-happen/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -3939,8 +3879,6 @@ "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], - "node-gyp/make-fetch-happen/cacache/@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], "node-gyp/make-fetch-happen/cacache/fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], diff --git a/package.json b/package.json index d2d0aefd..d0d8ffdf 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,6 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", - "@types/react-syntax-highlighter": "^15.5.13", "@xenova/transformers": "^2.17.2", "@zumer/snapdom": "^2.12.8", "bun-pty": "^0.4.5", @@ -115,7 +114,6 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^15.6.6", "remark-gfm": "^4.0.1", "simple-git": "^3.28.0", "sonner": "^2.0.7", From bd68e303d427c31a2362095e7d3e9bbc1834841f Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 17 Jul 2026 10:30:45 +0300 Subject: [PATCH 004/209] feat(chat): preserve pinned messages across compaction Add pin and unpin actions for user and assistant text messages, with clear compaction-survival labels, localized tooltips, status-info active styling, and VS Code gating where the server runtime is unavailable. Persist pinned message IDs, creation timestamps, and roles under the OpenChamber session metadata namespace using fresh-read merge updates so goal, review, and other metadata remain intact. Introduce a server runtime that reacts to OpenCode's dedicated session.compacted event, fetches pinned messages by ID, extracts and chronologically orders their text parts, and injects them as hidden synthetic context through prompt_async. The restoration prompt tells the agent to use the context silently while work remains and limits idle summaries to one short paragraph. Track the last handled compaction summary to avoid replay duplication, tolerate individually missing pinned messages, integrate runtime shutdown, document ownership and limitations, and cover metadata round trips plus compaction injection behavior with focused tests. --- .../ui/src/components/chat/ChatMessage.tsx | 40 +++++ .../components/chat/message/MessageBody.tsx | 62 +++++++- .../src/lib/contextObligatoryMessages.test.ts | 20 +++ .../ui/src/lib/contextObligatoryMessages.ts | 48 ++++++ packages/ui/src/lib/i18n/messages/en.ts | 3 + packages/ui/src/lib/i18n/messages/es.ts | 3 + packages/ui/src/lib/i18n/messages/fr.ts | 3 + packages/ui/src/lib/i18n/messages/ja.ts | 3 + packages/ui/src/lib/i18n/messages/ko.ts | 3 + packages/ui/src/lib/i18n/messages/pl.ts | 3 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 3 + packages/ui/src/lib/i18n/messages/uk.ts | 3 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 3 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 3 + packages/ui/src/sync/session-actions.ts | 14 ++ packages/web/server/index.js | 7 + .../lib/context-obligatory/DOCUMENTATION.md | 19 +++ .../server/lib/context-obligatory/runtime.js | 140 ++++++++++++++++++ .../lib/context-obligatory/runtime.test.js | 77 ++++++++++ .../server/lib/opencode/shutdown-runtime.js | 2 + 20 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/lib/contextObligatoryMessages.test.ts create mode 100644 packages/ui/src/lib/contextObligatoryMessages.ts create mode 100644 packages/web/server/lib/context-obligatory/DOCUMENTATION.md create mode 100644 packages/web/server/lib/context-obligatory/runtime.js create mode 100644 packages/web/server/lib/context-obligatory/runtime.test.js diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 320031eb..b3a577a2 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -31,6 +31,12 @@ import { FadeInOnReveal } from './message/FadeInOnReveal'; import { streamPerfCount } from '@/stores/utils/streamDebug'; import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare'; import type { ReviewTransferDirection } from '@/lib/reviewFlow'; +import { toast } from 'sonner'; +import { useI18n } from '@/lib/i18n'; +import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { getContextObligatoryMessages } from '@/lib/contextObligatoryMessages'; +import { setContextObligatoryMessage } from '@/sync/session-actions'; +import { isVSCodeRuntime } from '@/lib/desktop'; const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog')); @@ -150,8 +156,10 @@ const ChatMessage: React.FC = ({ onUserAnimationConsumed, reviewTransferDirection = null, }) => { + const { t } = useI18n(); const { isMobile, isTablet, hasTouchInput } = useDeviceInfo(); const alwaysShowMessageActions = isMobile || isTablet; + const canPinIntoContext = !isVSCodeRuntime(); const { currentTheme } = useThemeSystem(); const messageContainerRef = React.useRef(null); @@ -402,6 +410,29 @@ const ChatMessage: React.FC = ({ const timeInfo = message.info.time as { created?: number } | undefined; return typeof timeInfo?.created === 'number' ? timeInfo.created : null; }, [message.info.time]); + const isPinnedIntoContext = useGlobalSessionsStore((state) => { + const session = state.activeSessions.find((candidate) => candidate.id === sessionId) + ?? state.archivedSessions.find((candidate) => candidate.id === sessionId); + return getContextObligatoryMessages(session).some((entry) => entry.id === message.info.id); + }); + const [pinPending, setPinPending] = React.useState(false); + const handleToggleContextPin = React.useCallback(async () => { + if (!sessionId || !messageCreatedAt || pinPending) return; + setPinPending(true); + try { + const directory = useSessionUIStore.getState().getDirectoryForSession(sessionId); + await setContextObligatoryMessage(sessionId, directory, { + id: message.info.id, + createdAt: messageCreatedAt, + role: isUser ? 'user' : 'assistant', + }, !isPinnedIntoContext); + } catch (error) { + console.error('[chat-message] failed to update context pin', error); + toast.error(t('chat.messageBody.actions.contextPinFailed')); + } finally { + setPinPending(false); + } + }, [isPinnedIntoContext, isUser, message.info.id, messageCreatedAt, pinPending, sessionId, t]); const isMessageCompleted = React.useMemo(() => { if (isUser) return true; @@ -1038,6 +1069,9 @@ const ChatMessage: React.FC = ({ agentMention={agentMention} onRevert={handleRevert} onFork={isUser ? handleFork : undefined} + contextPinned={isPinnedIntoContext} + contextPinPending={pinPending} + onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined} errorMessage={assistantErrorText} errorVariant={assistantErrorVariant} userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'} @@ -1072,6 +1106,9 @@ const ChatMessage: React.FC = ({ agentMention={agentMention} onRevert={handleRevert} onFork={isUser ? handleFork : undefined} + contextPinned={isPinnedIntoContext} + contextPinPending={pinPending} + onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined} errorMessage={assistantErrorText} errorVariant={assistantErrorVariant} userActionsMode="external-actions" @@ -1104,6 +1141,9 @@ const ChatMessage: React.FC = ({ messageFinish={messageFinish} messageCompletedAt={messageCompletedAt ?? undefined} messageCreatedAt={messageCreatedAt ?? undefined} + contextPinned={isPinnedIntoContext} + contextPinPending={pinPending} + onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined} isMobile={isMobile} alwaysShowActions={alwaysShowMessageActions} hasTouchInput={hasTouchInput} diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 6563375f..e985e64f 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -434,6 +434,9 @@ interface MessageBodyProps { userActionsMode?: 'inline' | 'external-content' | 'external-actions'; stickyUserHeaderEnabled?: boolean; reviewTransferDirection?: ReviewTransferDirection | null; + contextPinned?: boolean; + contextPinPending?: boolean; + onToggleContextPin?: () => void; } const TOOL_REVEAL_CACHE_MAX = 200; @@ -454,7 +457,7 @@ const writeRevealedToolIds = (messageId: string, value: Set): void => { revealedToolIdsByMessage.set(messageId, new Set(value)); }; -const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobile, alwaysShowActions = isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }: { +const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobile, alwaysShowActions = isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, contextPinned, contextPinPending, onToggleContextPin, userActionsMode = 'inline', stickyUserHeaderEnabled = true }: { messageId: string; parts: Part[]; messageCreatedAt?: number | null; @@ -468,6 +471,9 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi agentMention?: AgentMentionInfo; onRevert?: () => void; onFork?: () => void; + contextPinned?: boolean; + contextPinPending?: boolean; + onToggleContextPin?: () => void; userActionsMode?: 'inline' | 'external-content' | 'external-actions'; stickyUserHeaderEnabled?: boolean; }) => { @@ -554,7 +560,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi const formatted = formatTimestampForDisplay(messageCreatedAt, timeFormatPreference); return formatted.length > 0 ? formatted : null; }, [locale, messageCreatedAt, timeFormatPreference]); - const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork) && showUserActions ? ( + const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
{t('chat.messageBody.actions.fork')} )} + {onToggleContextPin && hasCopyableText && ( + + + + + {t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')} + + )} {canCopyMessage && hasCopyableText && ( @@ -1057,6 +1086,9 @@ const AssistantMessageBody = React.memo(({ errorMessage, errorVariant = 'error', reviewTransferDirection = null, + contextPinned, + contextPinPending, + onToggleContextPin, }: Omit) => { const { t, locale } = useI18n(); const chatSurfaceMode = useChatSurfaceMode(); @@ -2003,6 +2035,29 @@ const AssistantMessageBody = React.memo(({ {t('chat.messageBody.actions.saveAsPlan')} ) : null} + {onToggleContextPin && hasCopyableText ? ( + + + + + {t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')} + + ) : null} {!isMiniChatSurface && !isReviewSessionView ? +
+ ))} {reviewCount > 0 ? (
= ({ part, messageId, agentMention }) => { const partWithText = part as PartWithText; const rawText = partWithText.text; - const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || ''; + const serializedText = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || ''; + const terminalContextState = React.useMemo(() => extractTerminalContexts(serializedText), [serializedText]); + const textContent = terminalContextState.visibleText; const [isExpanded, setIsExpanded] = React.useState(false); const [isTruncated, setIsTruncated] = React.useState(false); @@ -190,7 +193,7 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti }); }, [agentMention, openSkill, skillByName, textContent]); - if (!textContent || textContent.trim().length === 0) { + if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) { return null; } @@ -243,6 +246,18 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti plainTextContent )}
+ {terminalContextState.contexts.length > 0 ? ( +
+ {terminalContextState.contexts.map((context, index) => ( +
+ + {t('chat.message.terminalContext', { terminal: context.terminalLabel, start: context.startLine, end: context.endLine })} + +
{context.text}
+
+ ))} +
+ ) : null} ); }; diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 23086158..e195ba37 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -25,6 +25,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch'; import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth'; import { getRuntimeUrlResolver } from '@/lib/runtime-url'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; +import { getPreviewTargetRecoveryAction } from '@/lib/preview/proxy-response'; import { Icon } from "@/components/icon/Icon"; import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo"; import { invokeDesktopCommand } from '@/lib/desktopNative'; @@ -951,27 +952,37 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { // Out-of-band upstream probe: iframes don't expose HTTP status to the parent, // so when the proxy returns a 502 (upstream dev server is offline) the iframe - // would just render the raw JSON error body. Probe the proxy URL with a HEAD + // would just render the raw JSON error body. Probe the proxy URL with a GET // request and surface a friendly overlay when the upstream is unreachable. type UpstreamState = 'unknown' | 'starting' | 'reachable' | 'unreachable'; const [upstreamState, setUpstreamState] = React.useState('unknown'); const upstreamProbeStartedAtRef = React.useRef(0); const upstreamProbeAttemptRef = React.useRef(0); + const upstreamProbeKeyRef = React.useRef(''); + const proxyRecoveryAttemptedKeyRef = React.useRef(''); const PREVIEW_STARTUP_GRACE_MS = 15_000; React.useEffect(() => { if (!proxySrc) { setUpstreamState('unknown'); + upstreamProbeKeyRef.current = ''; upstreamProbeStartedAtRef.current = 0; upstreamProbeAttemptRef.current = 0; return; } let cancelled = false; - if (!upstreamProbeStartedAtRef.current) { + let retryTimeout: ReturnType | null = null; + if (upstreamProbeKeyRef.current !== proxyCacheKey) { + upstreamProbeKeyRef.current = proxyCacheKey; upstreamProbeStartedAtRef.current = Date.now(); upstreamProbeAttemptRef.current = 0; } + const scheduleRetry = (delay: number) => { + retryTimeout = setTimeout(() => { + if (!cancelled) bumpReload(); + }, delay); + }; setUpstreamState('unknown'); void (async () => { @@ -993,21 +1004,36 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { if (cancelled) return; if (!response) { - // Network-level failure (e.g. server itself is down) — treat as unreachable. setUpstreamState('unreachable'); + scheduleRetry(5000); return; } - if (response.status === 403 || response.status === 404) { + const recoveryAction = getPreviewTargetRecoveryAction( + response.headers, + proxyRecoveryAttemptedKeyRef.current === proxyCacheKey, + ); + if (recoveryAction !== 'none') { previewProxyTargetCache.delete(proxyCacheKey); - setProxyState({ status: 'loading' }); - bumpProxyRegistration(); + if (recoveryAction === 'retry-registration') { + proxyRecoveryAttemptedKeyRef.current = proxyCacheKey; + setProxyState({ status: 'loading' }); + bumpProxyRegistration(); + } else { + const errorBody = await response.json().catch(() => ({})); + if (cancelled) return; + const message = typeof errorBody?.error === 'string' + ? errorBody.error + : `HTTP ${response.status}`; + setProxyState({ status: 'error', message }); + } return; } // The proxy emits 502 when the upstream is unreachable. Anything else // (including 4xx from the upstream) means the upstream answered. if (response.status !== 502) { + proxyRecoveryAttemptedKeyRef.current = ''; setUpstreamState('reachable'); return; } @@ -1021,19 +1047,17 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { upstreamProbeAttemptRef.current += 1; const attempt = upstreamProbeAttemptRef.current; const delay = Math.min(2000, 250 * Math.pow(2, Math.min(4, attempt))); - setTimeout(() => { - if (!cancelled) { - bumpReload(); - } - }, delay).unref?.(); + scheduleRetry(delay); return; } setUpstreamState('unreachable'); + scheduleRetry(5000); })(); return () => { cancelled = true; + if (retryTimeout) clearTimeout(retryTimeout); }; }, [proxyCacheKey, proxySrc, reloadNonce]); diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 4c42a4ad..276aee87 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -64,7 +64,6 @@ import type { GitHubAuthStatus } from '@/lib/api/types'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher'; import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; -import { forceKillTerminal } from '@/lib/terminalApi'; import { useTerminalStore } from '@/stores/useTerminalStore'; import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton'; import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown'; @@ -1776,7 +1775,8 @@ export const Header: React.FC = ({ }, [shortcutOverrides]); useEffect(() => { - if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'terminal' || activeMainTab === 'diff' || activeMainTab === 'files' || activeMainTab === 'context')) { + // Project actions may intentionally promote the terminal to the desktop main view. + if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'diff' || activeMainTab === 'files' || activeMainTab === 'context')) { setActiveMainTab('chat'); } }, [activeMainTab, isMobile, setActiveMainTab]); @@ -1831,7 +1831,7 @@ export const Header: React.FC = ({ try { // Ensure preview/dev terminals don't linger. - await forceKillTerminal({}); + await runtimeApis.terminal.forceKill?.({}); } catch { // ignore } @@ -1856,7 +1856,7 @@ export const Header: React.FC = ({ setIsDevShutdownInFlight(false); } } - }, [isDevShutdownInFlight, setIsDesktopServicesOpen]); + }, [isDevShutdownInFlight, runtimeApis.terminal, setIsDesktopServicesOpen]); const quotaDisplayTabs = React.useMemo(() => { return [ diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 891e16fd..0efd43b4 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -16,6 +16,7 @@ import { SessionSidebar } from '@/components/session/SessionSidebar'; import { SessionDialogs } from '@/components/session/SessionDialogs'; import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider'; import { MultiRunLauncher } from '@/components/multirun'; +import { TerminalView } from '@/components/views/TerminalView'; import { DrawerProvider } from '@/contexts/DrawerContext'; import { useUIStore } from '@/stores/useUIStore'; @@ -31,8 +32,9 @@ import { FilesView } from '@/components/views/FilesView'; import { GitView } from '@/components/views/GitView'; import { PlanView } from '@/components/views/PlanView'; -// Heavy views loaded on-demand to reduce initial bundle parse time. -const TerminalView = lazyWithChunkRecovery(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView }))); +// Keep TerminalView eager: the bottom dock reserves its height immediately, so +// suspending here leaves a large blank panel on slower machines. +// Other heavy views stay on-demand to reduce initial bundle parse time. const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView }))); const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow }))); @@ -365,7 +367,7 @@ export const MainLayout: React.FC = () => { case 'diff': return ; case 'terminal': - return ; + return ; case 'files': return ; case 'context': @@ -539,12 +541,10 @@ export const MainLayout: React.FC = () => { - - {isBottomTerminalOpen ? ( + + {isBottomTerminalOpen && activeMainTab !== 'terminal' ? ( - - - + ) : null} diff --git a/packages/ui/src/components/layout/ProjectActionsButton.tsx b/packages/ui/src/components/layout/ProjectActionsButton.tsx index 5821babb..9fd823e2 100644 --- a/packages/ui/src/components/layout/ProjectActionsButton.tsx +++ b/packages/ui/src/components/layout/ProjectActionsButton.tsx @@ -15,6 +15,7 @@ import { useDeviceInfo } from '@/lib/device'; import { isDesktopShell } from '@/lib/desktop'; import { useUIStore } from '@/stores/useUIStore'; import { useTerminalStore } from '@/stores/useTerminalStore'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useDesktopSshStore } from '@/stores/useDesktopSshStore'; import { openExternalUrl } from '@/lib/url'; import { useI18n } from '@/lib/i18n'; @@ -31,7 +32,7 @@ import { toProjectActionRunKey, } from '@/lib/projectActions'; import { detectDevServerCommand, readPackageJsonScripts } from '@/lib/detectDevServer'; -import { connectTerminalStream } from '@/lib/terminalApi'; +import { waitForTerminalExit } from '@/lib/projectActionTerminal'; type UrlWatchEntry = { lastSeenChunkId: number | null; @@ -40,12 +41,6 @@ type UrlWatchEntry = { openInPreview: boolean; }; -const sleep = (ms: number): Promise => { - return new Promise((resolve) => { - window.setTimeout(resolve, ms); - }); -}; - interface ProjectActionsButtonProps { projectRef: ProjectRef | null; directory: string; @@ -154,6 +149,7 @@ export const ProjectActionsButton = ({ allowMobile = false, }: ProjectActionsButtonProps) => { const { t } = useI18n(); + const { currentTheme } = useThemeSystem(); const { terminal, runtime } = useRuntimeAPIs(); const { isMobile } = useDeviceInfo(); const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []); @@ -161,13 +157,14 @@ export const ProjectActionsButton = ({ const loadDesktopSsh = useDesktopSshStore((state) => state.load); const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen); + const terminalShell = useUIStore((state) => state.terminalShell); + const terminalLoginShell = useUIStore((state) => state.terminalLoginShells.includes(state.terminalShell)); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const setSettingsPage = useUIStore((state) => state.setSettingsPage); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSettingsProjectsSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId); const openContextPreview = useUIStore((state) => state.openContextPreview); - const terminalSessions = useTerminalStore((state) => state.sessions); const ensureDirectory = useTerminalStore((state) => state.ensureDirectory); const setTabLabel = useTerminalStore((state) => state.setTabLabel); const setTabIconKey = useTerminalStore((state) => state.setTabIconKey); @@ -187,6 +184,7 @@ export const ProjectActionsButton = ({ const urlWatchByRunKeyRef = React.useRef>({}); const streamCleanupByRunKeyRef = React.useRef void>>({}); const previewWaitTimeoutByRunKeyRef = React.useRef>({}); + const startingRunKeysRef = React.useRef>(new Set()); const loadRequestIdRef = React.useRef(0); const projectId = projectRef?.id ?? null; @@ -311,79 +309,66 @@ export const ProjectActionsButton = ({ }, [actions, canUseAutoDiscover, selectedActionId]); React.useEffect(() => { - for (const [key, entry] of Object.entries(projectActionRuns)) { - const directoryState = terminalSessions.get(entry.directory); - const tab = directoryState?.tabs.find((item) => item.id === entry.tabId); - if (!tab || tab.terminalSessionId !== entry.sessionId) { - removeProjectActionRun(key); - } - } - }, [projectActionRuns, removeProjectActionRun, terminalSessions]); - - React.useEffect(() => { - for (const [runKey, entry] of Object.entries(projectActionRuns)) { - const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false }; - urlWatchByRunKeyRef.current[runKey] = watch; - const action = displayActions.find((item) => item.id === entry.actionId); - if (!action) { - continue; - } - - const directoryState = terminalSessions.get(entry.directory); - const tab = directoryState?.tabs.find((item) => item.id === entry.tabId); - if (!tab || !Array.isArray(tab.bufferChunks) || tab.bufferChunks.length === 0) { - continue; - } - - const nextChunks = tab.bufferChunks.filter((chunk) => { - if (watch.lastSeenChunkId === null) { - return true; + const monitorRuns = () => { + const terminalSessions = useTerminalStore.getState().sessions; + const currentRuns = useTerminalStore.getState().projectActionRuns; + for (const [runKey, entry] of Object.entries(currentRuns)) { + const directoryState = terminalSessions.get(entry.directory); + const tab = directoryState?.tabs.find((item) => item.id === entry.tabId); + if (!tab || tab.terminalSessionId !== entry.sessionId) { + removeProjectActionRun(runKey); + continue; } - return chunk.id > watch.lastSeenChunkId; - }); - if (nextChunks.length === 0) { - continue; - } + const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false }; + urlWatchByRunKeyRef.current[runKey] = watch; + const action = displayActions.find((item) => item.id === entry.actionId); + if (!action || !Array.isArray(tab.bufferChunks) || tab.bufferChunks.length === 0) continue; - const combined = nextChunks.map((chunk) => chunk.data).join(''); - const textForScan = `${watch.tail}${combined}`; - const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true ? extractBestUrl(textForScan) : null; - const lastChunkId = nextChunks[nextChunks.length - 1]?.id ?? watch.lastSeenChunkId; + const nextChunks = tab.bufferChunks.filter((chunk) => watch.lastSeenChunkId === null || chunk.id > watch.lastSeenChunkId); + if (nextChunks.length === 0) continue; - watch.lastSeenChunkId = lastChunkId; - watch.tail = textForScan.slice(-512); + const combined = nextChunks.map((chunk) => chunk.data).join(''); + const textForScan = `${watch.tail}${combined}`; + const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true ? extractBestUrl(textForScan) : null; + const lastChunkId = nextChunks[nextChunks.length - 1]?.id ?? watch.lastSeenChunkId; - if (maybeUrl) { - watch.openedUrl = true; - if (watch.openInPreview) { - const run = projectActionRuns[runKey]; - if (run) { - setTabPreviewUrl(run.directory, run.tabId, maybeUrl, { locked: false, autoOpened: false }); - if (run.status === 'waiting-for-preview') { - updateProjectActionRunStatus(runKey, 'running'); + watch.lastSeenChunkId = lastChunkId; + watch.tail = textForScan.slice(-512); + + if (maybeUrl) { + watch.openedUrl = true; + if (watch.openInPreview) { + const run = currentRuns[runKey]; + if (run) { + setTabPreviewUrl(run.directory, run.tabId, maybeUrl, { locked: false, autoOpened: false }); + if (run.status === 'waiting-for-preview') updateProjectActionRunStatus(runKey, 'running'); + window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]); + delete previewWaitTimeoutByRunKeyRef.current[runKey]; + openContextPreview(run.directory, maybeUrl); } - window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]); - delete previewWaitTimeoutByRunKeyRef.current[runKey]; - openContextPreview(run.directory, maybeUrl); + } else { + void openExternal(maybeUrl); + toast.success(t('projectActions.toast.openedUrlFromOutput')); } - } else { - void openExternal(maybeUrl); - toast.success(t('projectActions.toast.openedUrlFromOutput')); + } + urlWatchByRunKeyRef.current[runKey] = watch; + } + + for (const runKey of Object.keys(urlWatchByRunKeyRef.current)) { + if (!currentRuns[runKey]) { + delete urlWatchByRunKeyRef.current[runKey]; + window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]); + delete previewWaitTimeoutByRunKeyRef.current[runKey]; } } - urlWatchByRunKeyRef.current[runKey] = watch; - } + }; - for (const runKey of Object.keys(urlWatchByRunKeyRef.current)) { - if (!projectActionRuns[runKey]) { - delete urlWatchByRunKeyRef.current[runKey]; - window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]); - delete previewWaitTimeoutByRunKeyRef.current[runKey]; - } - } - - }, [displayActions, openContextPreview, openExternal, projectActionRuns, setTabPreviewUrl, t, terminalSessions, updateProjectActionRunStatus]); + monitorRuns(); + return useTerminalStore.subscribe((state, previousState) => { + if (state.sessions !== previousState.sessions) monitorRuns(); + }); + }, [displayActions, openContextPreview, openExternal, projectActionRuns, removeProjectActionRun, setTabPreviewUrl, t, updateProjectActionRunStatus]); const getOrCreateActionTab = React.useCallback(async (action: OpenChamberProjectAction, options: { revealTerminal?: boolean } = {}) => { if (!normalizedDirectory) { @@ -408,8 +393,8 @@ export const ProjectActionsButton = ({ setTabLabel(normalizedDirectory, tabId, `Action: ${action.name}`); setTabIconKey(normalizedDirectory, tabId, action.icon || 'play'); + setActiveTab(normalizedDirectory, tabId); if (options.revealTerminal !== false) { - setActiveTab(normalizedDirectory, tabId); setBottomTerminalOpen(true); setActiveMainTab('terminal'); } @@ -447,6 +432,8 @@ export const ProjectActionsButton = ({ if (existingRun && existingRun.status === 'running') { return; } + if (startingRunKeysRef.current.has(runKey)) return; + startingRunKeysRef.current.add(runKey); try { const discovered = action.id === AUTO_DISCOVER_ACTION_ID @@ -471,16 +458,23 @@ export const ProjectActionsButton = ({ : action; const hasCustomOpenUrl = discovered.autoOpenUrl === true && (discovered.openUrl || '').trim().length > 0; - const { key, tabId, sessionId } = await getOrCreateActionTab(discovered, { revealTerminal: !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID }); + const revealTerminal = !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID; + const { key, tabId, sessionId } = await getOrCreateActionTab(discovered, { revealTerminal }); let activeSessionId = sessionId; - let createdSession = false; if (!activeSessionId) { setConnecting(normalizedDirectory, tabId, true); try { - const created = await terminal.createSession({ cwd: normalizedDirectory }); + const created = await terminal.createSession({ + cwd: normalizedDirectory, + sessionId: tabId, + shell: terminalShell, + loginShell: terminalLoginShell, + themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark', + terminalBackground: currentTheme.colors.surface.background, + terminalForeground: currentTheme.colors.syntax.base.foreground, + }); activeSessionId = created.sessionId; - createdSession = true; setTabSessionId(normalizedDirectory, tabId, activeSessionId); } finally { setConnecting(normalizedDirectory, tabId, false); @@ -491,18 +485,17 @@ export const ProjectActionsButton = ({ throw new Error(t('projectActions.error.failedToCreateTerminalSession')); } - if (createdSession) { - await sleep(350); - } - - if (discovered.id === AUTO_DISCOVER_ACTION_ID) { - streamCleanupByRunKeyRef.current[key]?.(); - setConnecting(normalizedDirectory, tabId, true); - streamCleanupByRunKeyRef.current[key] = connectTerminalStream( + streamCleanupByRunKeyRef.current[key]?.(); + setConnecting(normalizedDirectory, tabId, true); + const subscription = terminal.connect( activeSessionId, - (event) => { + { onEvent: (event) => { + if (event.type === 'snapshot') { + useTerminalStore.getState().replaceBuffer(normalizedDirectory, tabId, event.data ?? '', event.sequence ?? 0); + useTerminalStore.getState().setConnecting(normalizedDirectory, tabId, false); + } if (event.type === 'data' && typeof event.data === 'string' && event.data.length > 0) { - useTerminalStore.getState().appendToBuffer(normalizedDirectory, tabId, event.data); + useTerminalStore.getState().appendToBuffer(normalizedDirectory, tabId, event.data, event.sequence, event.replayData); } if (event.type === 'exit') { useTerminalStore.getState().setTabLifecycle(normalizedDirectory, tabId, 'exited'); @@ -514,13 +507,16 @@ export const ProjectActionsButton = ({ window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[key]); delete previewWaitTimeoutByRunKeyRef.current[key]; } - }, - () => { + }, onError: (_error, fatal) => { useTerminalStore.getState().setConnecting(normalizedDirectory, tabId, false); - }, - { maxRetries: 60, initialRetryDelay: 250, maxRetryDelay: 2000, connectionTimeout: 5000 }, + if (fatal) { + useTerminalStore.getState().setTabLifecycle(normalizedDirectory, tabId, 'exited'); + useTerminalStore.getState().setTabSessionId(normalizedDirectory, tabId, null); + useTerminalStore.getState().removeProjectActionRun(key); + } + } }, ); - } + streamCleanupByRunKeyRef.current[key] = subscription.close; const hasDesktopForwardSelection = discovered.autoOpenUrl === true && isDesktopShellApp @@ -542,11 +538,27 @@ export const ProjectActionsButton = ({ delete previewWaitTimeoutByRunKeyRef.current[key]; if (discovered.id === AUTO_DISCOVER_ACTION_ID && !manualOpenUrl) { previewWaitTimeoutByRunKeyRef.current[key] = window.setTimeout(() => { - useTerminalStore.getState().updateProjectActionRunStatus(key, 'running'); + const store = useTerminalStore.getState(); + const run = store.projectActionRuns[key]; + store.updateProjectActionRunStatus(key, 'running'); + if (run) { + store.setActiveTab(run.directory, run.tabId); + useUIStore.getState().setBottomTerminalOpen(true); + } delete previewWaitTimeoutByRunKeyRef.current[key]; }, AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS); } + urlWatchByRunKeyRef.current[key] = { + lastSeenChunkId: null, + openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl, + tail: '', + openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID, + }; + + const normalizedCommand = stripControlChars(discovered.command.trim().replace(/\r\n|\r/g, '\n')); + await terminal.sendInput(activeSessionId, `${normalizedCommand}\r`); + if (desktopForwardUrl) { setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: true }); void openExternal(desktopForwardUrl); @@ -565,15 +577,6 @@ export const ProjectActionsButton = ({ setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: false, autoOpened: false }); } - urlWatchByRunKeyRef.current[key] = { - lastSeenChunkId: null, - openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl, - tail: '', - openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID, - }; - - const normalizedCommand = stripControlChars(discovered.command.trim().replace(/\r\n|\r/g, '\n')); - await terminal.sendInput(activeSessionId, `${normalizedCommand}\r`); } catch (error) { removeProjectActionRun(runKey); delete urlWatchByRunKeyRef.current[runKey]; @@ -582,14 +585,21 @@ export const ProjectActionsButton = ({ window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]); delete previewWaitTimeoutByRunKeyRef.current[runKey]; toast.error(error instanceof Error ? error.message : t('projectActions.error.failedToRunAction')); + } finally { + startingRunKeysRef.current.delete(runKey); } }, [ + currentTheme.colors.surface.background, + currentTheme.colors.syntax.base.foreground, + currentTheme.metadata.variant, desktopSshInstances, getOrCreateActionTab, allowMobile, isMobile, isDesktopShellApp, normalizedDirectory, + terminalLoginShell, + terminalShell, openExternal, openContextPreview, projectActionRuns, @@ -613,22 +623,22 @@ export const ProjectActionsButton = ({ updateProjectActionRunStatus(runKey, 'stopping'); + const exitPromise = waitForTerminalExit(terminal, activeRun.sessionId, 1000); + try { await terminal.sendInput(activeRun.sessionId, '\x03'); } catch { // noop } - await new Promise((resolve) => { - window.setTimeout(resolve, 1000); - }); + const exitObserved = await exitPromise; const afterTab = useTerminalStore.getState().getDirectoryState(activeRun.directory)?.tabs .find((entry) => entry.id === activeRun.tabId); const sessionStillSame = afterTab?.terminalSessionId === activeRun.sessionId; - if (sessionStillSame) { + if (sessionStillSame && !exitObserved) { if (typeof terminal.forceKill === 'function') { try { await terminal.forceKill({ sessionId: activeRun.sessionId }); @@ -699,6 +709,13 @@ export const ProjectActionsButton = ({ setSettingsDialogOpen(true); }, [setSettingsDialogOpen, setSettingsPage, setSettingsProjectsSelectedId, stableProjectRef?.id]); + const previewAction = selectedAction ?? displayActions[0] ?? null; + const previewRun = previewAction ? projectActionRuns[toProjectActionRunKey(normalizedDirectory, previewAction.id)] : null; + const selectedRunPreviewUrl = useTerminalStore((state) => { + if (!previewRun) return null; + return state.sessions.get(previewRun.directory)?.tabs.find((tab) => tab.id === previewRun.tabId)?.previewUrl ?? null; + }); + if (runtime.isVSCode || (!allowMobile && isMobile) || !stableProjectRef || !normalizedDirectory) { return null; } @@ -716,9 +733,6 @@ export const ProjectActionsButton = ({ const selectedRunning = projectActionRuns[selectedRunKey]; const isStoppingSelected = selectedRunning?.status === 'stopping'; const isWaitingForSelectedPreview = selectedRunning?.status === 'waiting-for-preview'; - const selectedRunPreviewUrl = selectedRunning - ? terminalSessions.get(selectedRunning.directory)?.tabs.find((tab) => tab.id === selectedRunning.tabId)?.previewUrl ?? null - : null; const showSelectedPreviewButton = Boolean(selectedRunning && selectedRunPreviewUrl); const handleOpenSelectedPreview = () => { if (!selectedRunning || !selectedRunPreviewUrl) { diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 0027e549..603fef9e 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -139,6 +139,8 @@ const VisualSectionContent: React.FC = () => { 'inputBarOffset', 'expandedEditorToolbar', ...(!isVSCode ? ['terminalQuickKeys' as const] : []), + ...(!isVSCode ? ['terminalShell' as const] : []), + ...(!isVSCode ? ['terminalLoginShell' as const] : []), 'reportUsage', ]} />; }; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 61390212..9e99e849 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -33,6 +33,10 @@ import { setDirectoryShowHidden, useDirectoryShowHidden, } from '@/lib/directoryShowHidden'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import type { TerminalShellOption } from '@/lib/api/types'; +import { isTerminalShell } from '@/lib/terminalShell'; +import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; interface Option { id: T; @@ -245,7 +249,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' return mode === 'markdown' ? 'markdown' : 'plain'; }; -type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar'; +type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar'; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ @@ -256,6 +260,7 @@ export const OpenChamberVisualSettings: React.FC const { locale, locales, setLocale, label, t } = useI18n(); const tUnsafe = React.useCallback((key: string) => t(key as Parameters[0]), [t]); const { isMobile } = useDeviceInfo(); + const { terminal } = useRuntimeAPIs(); const { browserTab } = usePwaDetection(); const directoryShowHidden = useDirectoryShowHidden(); const showReasoningTraces = useUIStore(state => state.showReasoningTraces); @@ -297,6 +302,10 @@ export const OpenChamberVisualSettings: React.FC const setFontSize = useUIStore(state => state.setFontSize); const terminalFontSize = useUIStore(state => state.terminalFontSize); const setTerminalFontSize = useUIStore(state => state.setTerminalFontSize); + const terminalShell = useUIStore(state => state.terminalShell); + const setTerminalShell = useUIStore(state => state.setTerminalShell); + const terminalLoginShells = useUIStore(state => state.terminalLoginShells); + const setTerminalLoginShells = useUIStore(state => state.setTerminalLoginShells); const editorFontSize = useUIStore(state => state.editorFontSize); const setEditorFontSize = useUIStore(state => state.setEditorFontSize); const uiFont = useUIStore(state => state.uiFont); @@ -571,7 +580,7 @@ export const OpenChamberVisualSettings: React.FC ? hasLocalizationSettings : (shouldShow('theme') || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart')); const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || shouldShow('inputBarOffset'); - const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || shouldShow('fileEditorKeymap') || shouldShow('expandedEditorToolbar'); + const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('expandedEditorToolbar'); const hasBehaviorSettings = shouldShow('mermaidRendering') || shouldShow('userMessageRendering') || shouldShow('chatRenderMode') @@ -597,6 +606,41 @@ export const OpenChamberVisualSettings: React.FC const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab && !isDesktopShell() && !isVSCode; const showPwaOrientationSetting = shouldShow('pwaOrientation') && isWebRuntime() && !isDesktopShell() && !isVSCode; const showMobileKeyboardModeSetting = shouldShow('mobileKeyboardMode') && isWebRuntime() && !isDesktopShell() && !isVSCode && supportsMobileKeyboardResizeContent(); + const showTerminalShellSetting = (shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode; + const [availableTerminalShells, setAvailableTerminalShells] = React.useState([]); + const [terminalShellRuntimeEpoch, setTerminalShellRuntimeEpoch] = React.useState(0); + React.useEffect(() => subscribeRuntimeEndpointChanged(() => { + setAvailableTerminalShells([]); + setTerminalShellRuntimeEpoch((epoch) => epoch + 1); + }), []); + React.useEffect(() => { + let cancelled = false; + if (!showTerminalShellSetting || !terminal.listShells) return; + void terminal.listShells() + .then((shells) => { + if (!cancelled) setAvailableTerminalShells(shells); + }) + .catch(() => { + if (!cancelled) setAvailableTerminalShells([]); + }); + return () => { + cancelled = true; + }; + }, [showTerminalShellSetting, terminal, terminalShellRuntimeEpoch]); + const terminalShellOptions = React.useMemo(() => { + const explicitShells = availableTerminalShells.filter((shell) => shell.id !== 'auto'); + if (terminalShell === 'auto' || explicitShells.some((shell) => shell.id === terminalShell)) { + return explicitShells; + } + return [{ id: terminalShell, name: terminalShell, supportsLogin: false }, ...explicitShells]; + }, [availableTerminalShells, terminalShell]); + const terminalShellSupportsLogin = availableTerminalShells.find((shell) => shell.id === terminalShell)?.supportsLogin === true; + const terminalLoginShellEnabled = terminalLoginShells.includes(terminalShell); + const setTerminalLoginShellEnabled = (enabled: boolean) => { + setTerminalLoginShells(enabled + ? [...terminalLoginShells.filter((shell) => shell !== terminalShell), terminalShell] + : terminalLoginShells.filter((shell) => shell !== terminalShell)); + }; const [mobileLayoutPreference, setMobileLayoutPreference] = React.useState(() => getStoredMobileLayoutPreference()); const [pwaInstallName, setPwaInstallName] = React.useState(''); const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system'); @@ -1260,8 +1304,8 @@ export const OpenChamberVisualSettings: React.FC