Add standalone /chat page with full-page conversation UI

Extract shared rendering (chat-render.ts) and streaming hook
(use-chat-stream.ts) from ChatWidget so both the floating panel
and the new page share identical markdown/KaTeX/SSE logic.

New page features:
- Responsive sidebar (inline desktop, Sheet drawer mobile)
- Conversation search/filter
- Notebook picker via cmdk command palette
- Auto-growing multi-line textarea input
- Pop-out button on widget header to open /chat

ChatLayout.astro omits the floating widget to avoid duplicate UI.
Chat store gains selectedNotebookId for page-level notebook context.
shadcn-ui primitives (ScrollArea, Sheet, Command, Popover, etc.)
wired to existing SpiceBook dark theme tokens.
This commit is contained in:
Ryan Malloy 2026-02-23 18:54:34 -07:00
parent 70efde8aa6
commit c0639c775c
22 changed files with 2822 additions and 182 deletions

18
frontend/components.json Normal file
View File

@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/cn",
"ui": "@/components/ui",
"lib": "@/lib"
}
}

View File

@ -22,11 +22,19 @@
"@iconify-json/lucide": "^1.2.90",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.4.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@resvg/resvg-js": "^2.6.2",
"astro": "^5.0.0",
"astro-icon": "^1.1.5",
"astro-seo-meta": "^5.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"cmdk": "^1.1.1",
"dompurify": "^3.3.1",
"katex": "^0.16.33",
"lucide-react": "^0.468.0",
@ -1710,6 +1718,44 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.4",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz",
"integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.5"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@fontsource/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
@ -2433,6 +2479,698 @@
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
"license": "MIT"
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
"integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@resvg/resvg-js": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
@ -3784,6 +4522,18 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
@ -4739,6 +5489,18 @@
"node": ">=8"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/cli-boxes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
@ -4847,6 +5609,22 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -5139,6 +5917,12 @@
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/deterministic-object-hash": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/deterministic-object-hash/-/deterministic-object-hash-2.0.2.tgz",
@ -5637,6 +6421,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@ -7878,6 +8671,75 @@
"node": ">=0.10.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@ -8619,8 +9481,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-fest": {
"version": "4.41.0",
@ -9012,6 +9873,49 @@
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT"
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",

View File

@ -23,11 +23,19 @@
"@iconify-json/lucide": "^1.2.90",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.4.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@resvg/resvg-js": "^2.6.2",
"astro": "^5.0.0",
"astro-icon": "^1.1.5",
"astro-seo-meta": "^5.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"cmdk": "^1.1.1",
"dompurify": "^3.3.1",
"katex": "^0.16.33",
"lucide-react": "^0.468.0",

View File

@ -0,0 +1,91 @@
import { useCallback, useEffect, useRef } from 'react';
import { Send, Square, BookOpen } from 'lucide-react';
interface ChatInputProps {
value: string;
onChange: (value: string) => void;
onSend: () => void;
onAbort: () => void;
streaming: boolean;
notebookTitle?: string | null;
}
export default function ChatInput({
value,
onChange,
onSend,
onAbort,
streaming,
notebookTitle,
}: ChatInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
}, [value]);
// Focus on mount
useEffect(() => {
setTimeout(() => textareaRef.current?.focus(), 100);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!streaming && value.trim()) {
onSend();
}
}
},
[onSend, streaming, value],
);
return (
<div className="chat-page-input-container">
{notebookTitle && (
<div className="chat-page-input-context">
<BookOpen size={12} />
<span>{notebookTitle}</span>
</div>
)}
<div className="chat-page-input-row">
<textarea
ref={textareaRef}
className="chat-page-textarea"
placeholder="Ask about circuits, netlists, or simulation…"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
disabled={streaming}
rows={1}
/>
{streaming ? (
<button
className="chat-page-send-btn abort"
onClick={onAbort}
title="Stop generating"
>
<Square size={16} />
</button>
) : (
<button
className="chat-page-send-btn"
onClick={onSend}
disabled={!value.trim()}
title="Send (Enter)"
>
<Send size={16} />
</button>
)}
</div>
<div className="chat-page-input-hint">
<span>Enter to send, Shift+Enter for newline</span>
</div>
</div>
);
}

View File

@ -0,0 +1,84 @@
import { useEffect, useRef } from 'react';
import type { ChatMessage } from '../../lib/chat-store';
import { renderMarkdown } from '../../lib/chat-render';
import 'katex/dist/katex.min.css';
function MessageBubble({ msg, isStreaming }: { msg: ChatMessage; isStreaming?: boolean }) {
return (
<div className={`chat-page-bubble ${msg.role}`}>
{msg.role === 'assistant' ? (
<>
<span dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.text) }} />
{isStreaming && <span className="chat-page-cursor" />}
</>
) : (
<span className="whitespace-pre-wrap">{msg.text}</span>
)}
</div>
);
}
interface ChatMessagesProps {
messages: ChatMessage[];
streaming: boolean;
statusText: string;
reasoningText: string;
reasoningTime: number;
}
export default function ChatMessages({
messages,
streaming,
statusText,
reasoningText,
reasoningTime,
}: ChatMessagesProps) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = bottomRef.current?.parentElement;
if (container) {
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
}
}, [messages.length, streaming]);
return (
<div className="chat-page-messages">
{messages.length === 0 && !streaming && (
<div className="chat-page-empty">
<div className="chat-page-empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
</div>
<h2 className="text-lg font-semibold text-[var(--color-sb-text-bright)]">Circuit Assistant</h2>
<p className="text-sm text-[var(--color-sb-muted)] max-w-md text-center">
Ask about circuits, netlists, SPICE simulation, or component behavior.
Select a notebook for context-aware answers.
</p>
</div>
)}
{messages.map((msg, i) => (
<MessageBubble
key={`${msg.timestamp}-${i}`}
msg={msg}
isStreaming={streaming && i === messages.length - 1 && msg.role === 'assistant'}
/>
))}
{reasoningText && streaming && (
<details className="chat-page-reasoning">
<summary>Thinking{reasoningTime > 0 ? ` (${reasoningTime}s)` : '…'}</summary>
<div className="chat-page-reasoning-body">{reasoningText}</div>
</details>
)}
{statusText && (
<div className="chat-page-status">{statusText}</div>
)}
<div ref={bottomRef} />
</div>
);
}

View File

@ -0,0 +1,129 @@
import { useCallback, useState } from 'react';
import { Zap, PanelLeftClose, PanelLeft } from 'lucide-react';
import { useChatStore } from '../../lib/chat-store';
import { useChatStream } from '../../lib/use-chat-stream';
import { Sheet, SheetContent, SheetTitle } from '../ui/sheet';
import { TooltipProvider } from '../ui/tooltip';
import ChatSidebar from './ChatSidebar';
import ChatMessages from './ChatMessages';
import ChatInput from './ChatInput';
import '../../styles/chat-page.css';
export default function ChatPage() {
const getActiveConversation = useChatStore((s) => s.getActiveConversation);
const createConversation = useChatStore((s) => s.createConversation);
const selectedNotebookId = useChatStore((s) => s.selectedNotebookId);
const selectedNotebookMeta = useChatStore((s) => s.selectedNotebookMeta);
// Build notebook override for the streaming hook
const notebookOverride = selectedNotebookId && selectedNotebookMeta
? {
notebook_id: selectedNotebookId,
title: selectedNotebookMeta.title,
engine: selectedNotebookMeta.engine,
}
: null;
const {
sendMessage,
abort,
streaming,
statusText,
reasoningText,
reasoningTime,
} = useChatStream({ notebookOverride });
const [input, setInput] = useState('');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [desktopSidebarVisible, setDesktopSidebarVisible] = useState(true);
const activeConv = getActiveConversation();
const messages = activeConv?.messages ?? [];
const handleSend = useCallback(() => {
const question = input.trim();
if (!question || streaming) return;
setInput('');
sendMessage(question);
}, [input, streaming, sendMessage]);
const handleNewConversation = useCallback(() => {
if (streaming) abort();
createConversation();
setSidebarOpen(false);
setInput('');
}, [createConversation, streaming, abort]);
return (
<TooltipProvider>
<div className="chat-page-root">
{/* Header */}
<header className="chat-page-header">
<div className="flex items-center gap-3">
{/* Mobile sidebar trigger */}
<button
className="chat-page-menu-btn md:hidden"
onClick={() => setSidebarOpen(true)}
aria-label="Open sidebar"
>
<PanelLeft size={18} />
</button>
{/* Desktop sidebar toggle */}
<button
className="chat-page-menu-btn hidden md:flex"
onClick={() => setDesktopSidebarVisible(!desktopSidebarVisible)}
aria-label={desktopSidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
>
{desktopSidebarVisible ? <PanelLeftClose size={18} /> : <PanelLeft size={18} />}
</button>
<a href="/" className="chat-page-logo">
<Zap size={18} className="text-[var(--color-sb-accent)]" />
<span>SpiceBook</span>
</a>
</div>
<div className="chat-page-header-title">
{activeConv?.title ?? 'Circuit Assistant'}
</div>
<div className="w-10" /> {/* Spacer for centering */}
</header>
{/* Main content */}
<div className="chat-page-body">
{/* Desktop sidebar */}
{desktopSidebarVisible && (
<aside className="chat-page-desktop-sidebar">
<ChatSidebar onNewConversation={handleNewConversation} />
</aside>
)}
{/* Mobile sidebar (Sheet drawer) */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetContent side="left" className="p-0 w-[300px]">
<SheetTitle className="sr-only">Conversations</SheetTitle>
<ChatSidebar onNewConversation={handleNewConversation} />
</SheetContent>
</Sheet>
{/* Message area */}
<main className="chat-page-main">
<ChatMessages
messages={messages}
streaming={streaming}
statusText={statusText}
reasoningText={reasoningText}
reasoningTime={reasoningTime}
/>
<ChatInput
value={input}
onChange={setInput}
onSend={handleSend}
onAbort={abort}
streaming={streaming}
notebookTitle={selectedNotebookMeta?.title}
/>
</main>
</div>
</div>
</TooltipProvider>
);
}

View File

@ -0,0 +1,111 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Plus, MessageSquare, Trash2, Search } from 'lucide-react';
import { useChatStore } from '../../lib/chat-store';
import { ScrollArea } from '../ui/scroll-area';
import { Separator } from '../ui/separator';
import NotebookPicker from './NotebookPicker';
interface ChatSidebarProps {
onNewConversation: () => void;
}
export default function ChatSidebar({ onNewConversation }: ChatSidebarProps) {
const conversations = useChatStore((s) => s.conversations);
const activeId = useChatStore((s) => s.activeConversationId);
const setActive = useChatStore((s) => s.setActiveConversation);
const deleteConv = useChatStore((s) => s.deleteConversation);
const [search, setSearch] = useState('');
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const pendingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const filtered = search.trim()
? conversations.filter((c) =>
c.title.toLowerCase().includes(search.toLowerCase()),
)
: conversations;
const handleDelete = useCallback(
(id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (pendingDeleteId === id) {
if (pendingTimer.current) clearTimeout(pendingTimer.current);
setPendingDeleteId(null);
deleteConv(id);
} else {
if (pendingTimer.current) clearTimeout(pendingTimer.current);
setPendingDeleteId(id);
pendingTimer.current = setTimeout(() => setPendingDeleteId(null), 3000);
}
},
[pendingDeleteId, deleteConv],
);
useEffect(() => {
return () => {
if (pendingTimer.current) clearTimeout(pendingTimer.current);
};
}, []);
return (
<div className="chat-page-sidebar">
{/* New conversation */}
<div className="chat-page-sidebar-header">
<button className="chat-page-new-btn" onClick={onNewConversation}>
<Plus size={14} />
<span>New conversation</span>
</button>
</div>
{/* Search */}
<div className="chat-page-sidebar-search">
<Search size={14} className="text-[var(--color-sb-muted)]" />
<input
type="text"
placeholder="Search conversations…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="chat-page-sidebar-search-input"
/>
</div>
{/* Conversation list */}
<ScrollArea className="flex-1">
<div className="chat-page-conv-list">
{filtered.length === 0 && (
<div className="px-3 py-8 text-center text-sm text-[var(--color-sb-muted)]">
{search ? 'No matching conversations' : 'No conversations yet'}
</div>
)}
{filtered.map((c) => (
<button
key={c.id}
className={`chat-page-conv-item ${c.id === activeId ? 'active' : ''}`}
onClick={() => setActive(c.id)}
>
<MessageSquare size={14} className="shrink-0" />
<div className="flex-1 min-w-0">
<div className="truncate text-sm">{c.title}</div>
<div className="text-xs text-[var(--color-sb-muted)]">
{c.messages.length} message{c.messages.length !== 1 ? 's' : ''}
</div>
</div>
<span
className={`chat-page-conv-delete ${pendingDeleteId === c.id ? 'confirm' : ''}`}
onClick={(e) => handleDelete(c.id, e)}
title={pendingDeleteId === c.id ? 'Click again to delete' : 'Delete'}
>
<Trash2 size={12} />
</span>
</button>
))}
</div>
</ScrollArea>
<Separator />
{/* Notebook picker */}
<NotebookPicker />
</div>
);
}

View File

@ -7,70 +7,15 @@ import {
MessageSquare,
Send,
Trash2,
ExternalLink,
} from 'lucide-react';
import { useChatStore } from '../../lib/chat-store';
import type { ChatMessage } from '../../lib/chat-store';
import { streamChat } from '../../lib/chat-api';
import { useNotebookStore } from '../../lib/notebook-store';
import { marked } from 'marked';
import markedKatex from 'marked-katex-extension';
import DOMPurify from 'dompurify';
import { renderMarkdown } from '../../lib/chat-render';
import { useChatStream } from '../../lib/use-chat-stream';
import 'katex/dist/katex.min.css';
import '../../styles/chat-widget.css';
// ── Markdown rendering (marked + KaTeX + DOMPurify) ──────
marked.setOptions({
breaks: true,
gfm: true,
});
marked.use(markedKatex({
throwOnError: false,
nonStandard: true,
}));
// KaTeX generates SVG + spans with specific classes/attributes.
// DOMPurify must allow these through.
const KATEX_TAGS = [
'math', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub',
'mfrac', 'munderover', 'mover', 'munder', 'msqrt', 'mroot',
'mtable', 'mtr', 'mtd', 'mspace', 'mtext', 'menclose',
'annotation', 'annotation-xml',
];
const KATEX_ATTRS = [
'xmlns', 'encoding', 'mathvariant', 'displaystyle', 'scriptlevel',
'fence', 'stretchy', 'symmetric', 'lspace', 'rspace',
'linethickness', 'columnalign', 'rowalign', 'columnspacing',
'rowspacing', 'columnlines', 'rowlines', 'frame', 'framespacing',
'width', 'height', 'voffset', 'accent', 'accentunder',
'notation', 'minsize', 'maxsize', 'movablelimits',
'aria-hidden', 'focusable', 'role', 'tabindex',
'viewBox', 'preserveAspectRatio', 'd', 'fill', 'stroke',
'stroke-width', 'stroke-linecap', 'stroke-linejoin',
'transform', 'clip-path',
];
// LLMs often emit $$...$$ inline (e.g. "is:$$\frac{1}{s}$$\nNext")
// but marked-katex-extension requires $$ on its own line for display mode.
// This normalizer ensures $$ delimiters get their own lines.
function normalizeDisplayMath(text: string): string {
return text.replace(/\$\$([\s\S]*?)\$\$/g, (_match, inner: string) => {
const trimmed = inner.trim();
return `\n$$\n${trimmed}\n$$\n`;
});
}
function renderMarkdown(text: string): string {
const normalized = normalizeDisplayMath(text);
const raw = marked.parse(normalized, { async: false }) as string;
return DOMPurify.sanitize(raw, {
ADD_TAGS: KATEX_TAGS,
ADD_ATTR: [...KATEX_ATTRS, 'target', 'class', 'style'],
});
}
// ── Components ──────────────────────────────────────────
function MessageBubble({ msg, isStreaming }: { msg: ChatMessage; isStreaming?: boolean }) {
@ -163,36 +108,25 @@ function HistoryView({
export default function ChatWidget() {
const panelOpen = useChatStore((s) => s.panelOpen);
const streaming = useChatStore((s) => s.streaming);
const togglePanel = useChatStore((s) => s.togglePanel);
const closePanel = useChatStore((s) => s.closePanel);
const createConversation = useChatStore((s) => s.createConversation);
const addUserMessage = useChatStore((s) => s.addUserMessage);
const addAssistantMessage = useChatStore((s) => s.addAssistantMessage);
const appendToLastAssistant = useChatStore((s) => s.appendToLastAssistant);
const setStreaming = useChatStore((s) => s.setStreaming);
const getActiveConversation = useChatStore((s) => s.getActiveConversation);
const activeConversationId = useChatStore((s) => s.activeConversationId);
// Notebook context from the notebook editor (if on a notebook page)
const notebookId = useNotebookStore((s) => s.notebookId);
const notebook = useNotebookStore((s) => s.notebook);
const {
sendMessage,
abort,
streaming,
statusText,
reasoningText,
reasoningTime,
} = useChatStream();
const [input, setInput] = useState('');
const [statusText, setStatusText] = useState('');
const [showHistory, setShowHistory] = useState(false);
const [reasoningText, setReasoningText] = useState('');
const [reasoningTime, setReasoningTime] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(null);
// Token batching: accumulate tokens in a ref, flush to state once per animation frame.
// Without this, React 19 batches all rapid set() calls from the SSE loop into one
// render at stream end — so the user sees nothing until the LLM finishes.
const pendingTokensRef = useRef('');
const flushRafRef = useRef(0);
const activeConv = getActiveConversation();
const messages = activeConv?.messages ?? [];
@ -228,97 +162,9 @@ export default function ChatWidget() {
const handleSend = useCallback(async () => {
const question = input.trim();
if (!question || streaming) return;
// Ensure there's an active conversation
if (!activeConversationId) {
createConversation();
}
setInput('');
addUserMessage(question);
addAssistantMessage('');
setStreaming(true);
setStatusText('');
setReasoningText('');
setReasoningTime(0);
const abortController = new AbortController();
abortRef.current = abortController;
const reasoningStart = Date.now();
try {
const notebookCtx = notebookId && notebook
? {
notebook_id: notebookId,
title: notebook.metadata.title,
engine: notebook.metadata.engine,
}
: null;
for await (const evt of streamChat({
question,
notebook: notebookCtx,
signal: abortController.signal,
})) {
switch (evt.event) {
case 'status':
setStatusText((evt.data as { text: string }).text);
break;
case 'token':
// Accumulate in ref; flush to Zustand once per animation frame
pendingTokensRef.current += (evt.data as { text: string }).text;
if (!flushRafRef.current) {
flushRafRef.current = requestAnimationFrame(() => {
if (pendingTokensRef.current) {
appendToLastAssistant(pendingTokensRef.current);
pendingTokensRef.current = '';
}
flushRafRef.current = 0;
});
setStatusText('');
}
break;
case 'reasoning':
setReasoningText((prev) => prev + (evt.data as { text: string }).text);
setReasoningTime(Math.round((Date.now() - reasoningStart) / 1000));
break;
case 'error':
appendToLastAssistant(
`\n\n*Error: ${(evt.data as { text: string }).text}*`,
);
break;
case 'done':
break;
}
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
// User cancelled — that's fine
} else {
appendToLastAssistant(
`\n\n*Error: ${err instanceof Error ? err.message : 'Connection failed'}*`,
);
}
} finally {
// Flush any remaining buffered tokens
if (flushRafRef.current) {
cancelAnimationFrame(flushRafRef.current);
flushRafRef.current = 0;
}
if (pendingTokensRef.current) {
appendToLastAssistant(pendingTokensRef.current);
pendingTokensRef.current = '';
}
setStreaming(false);
setStatusText('');
abortRef.current = null;
}
}, [
input, streaming, activeConversationId, notebookId, notebook,
createConversation, addUserMessage, addAssistantMessage,
appendToLastAssistant, setStreaming,
]);
await sendMessage(question);
}, [input, streaming, sendMessage]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@ -331,18 +177,17 @@ export default function ChatWidget() {
);
const handleNewConversation = useCallback(() => {
// If streaming, abort first
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
setStreaming(false);
if (streaming) {
abort();
}
createConversation();
setShowHistory(false);
setReasoningText('');
setReasoningTime(0);
setTimeout(() => inputRef.current?.focus(), 100);
}, [createConversation, setStreaming]);
}, [createConversation, streaming, abort]);
const handlePopOut = useCallback(() => {
window.open('/chat', '_blank');
}, []);
return (
<>
@ -367,6 +212,13 @@ export default function ChatWidget() {
? 'Conversations'
: activeConv?.title ?? 'Circuit Assistant'}
</span>
<button
className="chat-header-btn"
onClick={handlePopOut}
title="Open full chat page"
>
<ExternalLink size={16} />
</button>
<button
className="chat-header-btn"
onClick={handleNewConversation}
@ -430,11 +282,7 @@ export default function ChatWidget() {
ref={inputRef}
className="chat-input"
type="text"
placeholder={
notebookId
? 'Ask about this circuit…'
: 'Ask a circuit question…'
}
placeholder="Ask a circuit question…"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}

View File

@ -0,0 +1,97 @@
import { useCallback, useEffect, useState } from 'react';
import { BookOpen, X } from 'lucide-react';
import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover';
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../ui/command';
import { listNotebooks } from '../../lib/api';
import type { NotebookSummary } from '../../lib/types';
import { useChatStore } from '../../lib/chat-store';
export default function NotebookPicker() {
const [open, setOpen] = useState(false);
const [notebooks, setNotebooks] = useState<NotebookSummary[]>([]);
const [loading, setLoading] = useState(false);
const selectedId = useChatStore((s) => s.selectedNotebookId);
const selectedMeta = useChatStore((s) => s.selectedNotebookMeta);
const setSelected = useChatStore((s) => s.setSelectedNotebook);
const clearSelected = useChatStore((s) => s.clearSelectedNotebook);
useEffect(() => {
if (!open) return;
setLoading(true);
listNotebooks()
.then(setNotebooks)
.catch(() => setNotebooks([]))
.finally(() => setLoading(false));
}, [open]);
const handleSelect = useCallback(
(nb: NotebookSummary) => {
setSelected(nb.id, { title: nb.title, engine: nb.engine });
setOpen(false);
},
[setSelected],
);
const handleClear = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
clearSelected();
},
[clearSelected],
);
return (
<div className="chat-page-notebook-picker">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button className="chat-page-notebook-btn">
<BookOpen size={14} />
<span className="flex-1 text-left truncate">
{selectedMeta ? selectedMeta.title : 'Select notebook…'}
</span>
{selectedId && (
<span
className="chat-page-notebook-clear"
onClick={handleClear}
title="Clear selection"
>
<X size={12} />
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start" side="top">
<Command>
<CommandInput placeholder="Search notebooks…" />
<CommandList>
<CommandEmpty>
{loading ? 'Loading…' : 'No notebooks found'}
</CommandEmpty>
<CommandGroup>
{notebooks.map((nb) => (
<CommandItem
key={nb.id}
value={`${nb.title} ${nb.id}`}
onSelect={() => handleSelect(nb)}
>
<BookOpen size={14} className="shrink-0 text-[var(--color-sb-muted)]" />
<div className="flex flex-col gap-0.5 overflow-hidden">
<span className="truncate text-sm">{nb.title}</span>
<span className="text-xs text-[var(--color-sb-muted)]">
{nb.engine} · {nb.cell_count} cell{nb.cell_count !== 1 ? 's' : ''}
</span>
</div>
{nb.id === selectedId && (
<span className="ml-auto text-[var(--color-sb-accent)] text-xs"></span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View File

@ -0,0 +1,111 @@
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '../../lib/cn';
const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-lg bg-[var(--color-sb-surface)] text-[var(--color-sb-text)]',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b border-[var(--color-sb-border)] px-3" cmdk-input-wrapper="">
<Search size={14} className="mr-2 shrink-0 text-[var(--color-sb-muted)]" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-9 w-full rounded-md bg-transparent py-2 text-sm outline-none placeholder:text-[var(--color-sb-muted)] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm text-[var(--color-sb-muted)]" {...props} />
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-[var(--color-sb-text)] [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-[var(--color-sb-muted)]',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandItem = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none',
'data-[selected=true]:bg-[var(--color-sb-cell)] data-[selected=true]:text-[var(--color-sb-text-bright)]',
'data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandSeparator = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-[var(--color-sb-border)]', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
};

View File

@ -0,0 +1,32 @@
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '../../lib/cn';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-lg border border-[var(--color-sb-border)] bg-[var(--color-sb-surface)] p-4 shadow-md outline-none',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@ -0,0 +1,43 @@
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '../../lib/cn';
const ScrollArea = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-[var(--color-sb-border)] hover:bg-[var(--color-sb-muted)]" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@ -0,0 +1,23 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '../../lib/cn';
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-[var(--color-sb-border)]',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@ -0,0 +1,88 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '../../lib/cn';
const Sheet = DialogPrimitive.Root;
const SheetTrigger = DialogPrimitive.Trigger;
const SheetClose = DialogPrimitive.Close;
const SheetPortal = DialogPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
side?: 'top' | 'bottom' | 'left' | 'right';
}
const SheetContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ side = 'left', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed z-50 flex flex-col gap-4 bg-[var(--color-sb-bg)] shadow-lg transition ease-in-out',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-300 data-[state=closed]:duration-200',
side === 'left' &&
'inset-y-0 left-0 h-full w-3/4 max-w-[320px] border-r border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left',
side === 'right' &&
'inset-y-0 right-0 h-full w-3/4 max-w-[320px] border-l border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
side === 'top' &&
'inset-x-0 top-0 border-b border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
side === 'bottom' &&
'inset-x-0 bottom-0 border-t border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 text-[var(--color-sb-muted)] hover:text-[var(--color-sb-text)]">
<X size={16} />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = DialogPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col gap-2 px-4 pt-4', className)} {...props} />
);
SheetHeader.displayName = 'SheetHeader';
const SheetTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-sm font-semibold text-[var(--color-sb-text-bright)]', className)}
{...props}
/>
));
SheetTitle.displayName = DialogPrimitive.Title.displayName;
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetTitle,
};

View File

@ -0,0 +1,31 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '../../lib/cn';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-[var(--color-sb-cell)] px-3 py-1.5 text-xs text-[var(--color-sb-text)]',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -0,0 +1,67 @@
---
import { Seo } from 'astro-seo-meta';
import type { SEOProps } from '../lib/seo';
import { SEO_DEFAULTS } from '../lib/seo';
interface Props extends SEOProps {
title?: string;
}
const {
title = 'Chat | SpiceBook',
description,
ogImage,
ogType = 'website',
noindex = false,
canonicalPath,
} = Astro.props;
const pageTitle = title === 'SpiceBook' ? title : `${title} | SpiceBook`;
const pageDescription = description || SEO_DEFAULTS.defaultDescription;
const ogImageUrl = `${SEO_DEFAULTS.siteUrl}${ogImage || SEO_DEFAULTS.defaultOgImage}`;
const canonicalUrl = canonicalPath
? `${SEO_DEFAULTS.siteUrl}${canonicalPath}`
: undefined;
---
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
<Seo
title={pageTitle}
description={pageDescription}
icon="/favicon.svg"
colorScheme="dark"
robots={noindex ? 'noindex, nofollow' : 'index, follow'}
facebook={{
image: ogImageUrl,
url: canonicalUrl || SEO_DEFAULTS.siteUrl,
type: ogType,
}}
twitter={{
image: ogImageUrl,
card: 'summary_large_image',
}}
/>
<meta property="og:site_name" content={SEO_DEFAULTS.siteName} />
</head>
<body class="bg-slate-950 text-slate-200 min-h-screen antialiased">
<slot />
<script>
// Clean up stale service workers — SpiceBook doesn't use one
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then((regs) => {
for (const reg of regs) reg.unregister();
});
}
</script>
</body>
</html>
<style is:global>
@import '../styles/globals.css';
</style>

View File

@ -0,0 +1,68 @@
/**
* Shared markdown + KaTeX rendering for SpiceBook chat.
* Used by both the floating ChatWidget and the full /chat page.
*/
import { marked } from 'marked';
import markedKatex from 'marked-katex-extension';
import DOMPurify from 'dompurify';
// ── Configure marked once at module load ─────────────────
marked.setOptions({
breaks: true,
gfm: true,
});
marked.use(markedKatex({
throwOnError: false,
nonStandard: true,
}));
// ── KaTeX allow-lists for DOMPurify ─────────────────────
export const KATEX_TAGS = [
'math', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub',
'mfrac', 'munderover', 'mover', 'munder', 'msqrt', 'mroot',
'mtable', 'mtr', 'mtd', 'mspace', 'mtext', 'menclose',
'annotation', 'annotation-xml',
];
export const KATEX_ATTRS = [
'xmlns', 'encoding', 'mathvariant', 'displaystyle', 'scriptlevel',
'fence', 'stretchy', 'symmetric', 'lspace', 'rspace',
'linethickness', 'columnalign', 'rowalign', 'columnspacing',
'rowspacing', 'columnlines', 'rowlines', 'frame', 'framespacing',
'width', 'height', 'voffset', 'accent', 'accentunder',
'notation', 'minsize', 'maxsize', 'movablelimits',
'aria-hidden', 'focusable', 'role', 'tabindex',
'viewBox', 'preserveAspectRatio', 'd', 'fill', 'stroke',
'stroke-width', 'stroke-linecap', 'stroke-linejoin',
'transform', 'clip-path',
];
// ── Rendering functions ─────────────────────────────────
/**
* LLMs often emit $$...$$ inline (e.g. "is:$$\frac{1}{s}$$\nNext")
* but marked-katex-extension requires $$ on its own line for display mode.
* This normalizer ensures $$ delimiters get their own lines.
*/
export function normalizeDisplayMath(text: string): string {
return text.replace(/\$\$([\s\S]*?)\$\$/g, (_match, inner: string) => {
const trimmed = inner.trim();
return `\n$$\n${trimmed}\n$$\n`;
});
}
/**
* Render markdown text to sanitized HTML with KaTeX support.
*/
export function renderMarkdown(text: string): string {
const normalized = normalizeDisplayMath(text);
const raw = marked.parse(normalized, { async: false }) as string;
return DOMPurify.sanitize(raw, {
ADD_TAGS: KATEX_TAGS,
ADD_ATTR: [...KATEX_ATTRS, 'target', 'class', 'style'],
});
}

View File

@ -23,11 +23,18 @@ export interface Conversation {
const MAX_CONVERSATIONS = 20;
const MAX_MESSAGES = 50;
export interface SelectedNotebookMeta {
title: string;
engine: string;
}
interface ChatStore {
conversations: Conversation[];
activeConversationId: string | null;
panelOpen: boolean;
streaming: boolean;
selectedNotebookId: string | null;
selectedNotebookMeta: SelectedNotebookMeta | null;
// Actions
openPanel: () => void;
@ -41,6 +48,8 @@ interface ChatStore {
setStreaming: (streaming: boolean) => void;
deleteConversation: (id: string) => void;
getActiveConversation: () => Conversation | null;
setSelectedNotebook: (id: string, meta: SelectedNotebookMeta) => void;
clearSelectedNotebook: () => void;
}
function generateId(): string {
@ -59,6 +68,8 @@ export const useChatStore = create<ChatStore>()(
activeConversationId: null,
panelOpen: false,
streaming: false,
selectedNotebookId: null,
selectedNotebookMeta: null,
openPanel: () => set({ panelOpen: true }),
closePanel: () => set({ panelOpen: false }),
@ -158,12 +169,20 @@ export const useChatStore = create<ChatStore>()(
const s = get();
return s.conversations.find((c) => c.id === s.activeConversationId) ?? null;
},
setSelectedNotebook: (id: string, meta: SelectedNotebookMeta) =>
set({ selectedNotebookId: id, selectedNotebookMeta: meta }),
clearSelectedNotebook: () =>
set({ selectedNotebookId: null, selectedNotebookMeta: null }),
}),
{
name: 'spicebook-chat',
partialize: (state) => ({
conversations: state.conversations,
activeConversationId: state.activeConversationId,
selectedNotebookId: state.selectedNotebookId,
selectedNotebookMeta: state.selectedNotebookMeta,
}),
},
),

View File

@ -0,0 +1,176 @@
/**
* Shared streaming hook for SpiceBook chat.
* Handles SSE event dispatch, RAF token batching, abort, and reasoning state.
* Used by both the floating ChatWidget and the full /chat page.
*/
import { useCallback, useRef, useState } from 'react';
import { useChatStore } from './chat-store';
import { useNotebookStore } from './notebook-store';
import { streamChat } from './chat-api';
export interface ChatStreamState {
statusText: string;
reasoningText: string;
reasoningTime: number;
}
export interface UseChatStreamReturn extends ChatStreamState {
/** Send a message. Creates a conversation if none is active. */
sendMessage: (text: string) => Promise<void>;
/** Abort the current stream. */
abort: () => void;
/** Whether a stream is currently in progress. */
streaming: boolean;
}
interface UseChatStreamOptions {
/** Override notebook context (for the /chat page's notebook picker). */
notebookOverride?: {
notebook_id: string;
title: string;
engine: string;
} | null;
}
export function useChatStream(opts?: UseChatStreamOptions): UseChatStreamReturn {
const streaming = useChatStore((s) => s.streaming);
const activeConversationId = useChatStore((s) => s.activeConversationId);
const createConversation = useChatStore((s) => s.createConversation);
const addUserMessage = useChatStore((s) => s.addUserMessage);
const addAssistantMessage = useChatStore((s) => s.addAssistantMessage);
const appendToLastAssistant = useChatStore((s) => s.appendToLastAssistant);
const setStreaming = useChatStore((s) => s.setStreaming);
// Notebook context from the notebook store (widget on notebook pages)
const notebookId = useNotebookStore((s) => s.notebookId);
const notebook = useNotebookStore((s) => s.notebook);
const [statusText, setStatusText] = useState('');
const [reasoningText, setReasoningText] = useState('');
const [reasoningTime, setReasoningTime] = useState(0);
const abortRef = useRef<AbortController | null>(null);
// Token batching: accumulate tokens in a ref, flush to state once per
// animation frame. Without this, React 19 batches all rapid set() calls
// from the SSE loop into one render at stream end — so the user sees
// nothing until the LLM finishes.
const pendingTokensRef = useRef('');
const flushRafRef = useRef(0);
const abort = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
setStreaming(false);
}
}, [setStreaming]);
const sendMessage = useCallback(async (text: string) => {
const question = text.trim();
if (!question || streaming) return;
// Ensure there's an active conversation
if (!activeConversationId) {
createConversation();
}
addUserMessage(question);
addAssistantMessage('');
setStreaming(true);
setStatusText('');
setReasoningText('');
setReasoningTime(0);
const abortController = new AbortController();
abortRef.current = abortController;
const reasoningStart = Date.now();
try {
// Notebook context: prefer explicit override (from picker), fall back
// to the notebook store (when widget is on a notebook page)
const notebookCtx = opts?.notebookOverride
?? (notebookId && notebook
? {
notebook_id: notebookId,
title: notebook.metadata.title,
engine: notebook.metadata.engine,
}
: null);
for await (const evt of streamChat({
question,
notebook: notebookCtx,
signal: abortController.signal,
})) {
switch (evt.event) {
case 'status':
setStatusText((evt.data as { text: string }).text);
break;
case 'token':
// Accumulate in ref; flush to Zustand once per animation frame
pendingTokensRef.current += (evt.data as { text: string }).text;
if (!flushRafRef.current) {
flushRafRef.current = requestAnimationFrame(() => {
if (pendingTokensRef.current) {
appendToLastAssistant(pendingTokensRef.current);
pendingTokensRef.current = '';
}
flushRafRef.current = 0;
});
setStatusText('');
}
break;
case 'reasoning':
setReasoningText((prev) => prev + (evt.data as { text: string }).text);
setReasoningTime(Math.round((Date.now() - reasoningStart) / 1000));
break;
case 'error':
appendToLastAssistant(
`\n\n*Error: ${(evt.data as { text: string }).text}*`,
);
break;
case 'done':
break;
}
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
// User cancelled — that's fine
} else {
appendToLastAssistant(
`\n\n*Error: ${err instanceof Error ? err.message : 'Connection failed'}*`,
);
}
} finally {
// Flush any remaining buffered tokens
if (flushRafRef.current) {
cancelAnimationFrame(flushRafRef.current);
flushRafRef.current = 0;
}
if (pendingTokensRef.current) {
appendToLastAssistant(pendingTokensRef.current);
pendingTokensRef.current = '';
}
setStreaming(false);
setStatusText('');
abortRef.current = null;
}
}, [
streaming, activeConversationId, notebookId, notebook,
opts?.notebookOverride,
createConversation, addUserMessage, addAssistantMessage,
appendToLastAssistant, setStreaming,
]);
return {
sendMessage,
abort,
streaming,
statusText,
reasoningText,
reasoningTime,
};
}

View File

@ -0,0 +1,8 @@
---
import ChatLayout from '../layouts/ChatLayout.astro';
import ChatPage from '../components/chat/ChatPage';
---
<ChatLayout title="Chat" description="Full-page circuit assistant with conversation history and notebook context" canonicalPath="/chat">
<ChatPage client:load />
</ChatLayout>

View File

@ -0,0 +1,657 @@
/* SpiceBook Full Chat Page — dark-only, full-viewport layout */
/* ── Root layout ─────────────────────────────────────── */
.chat-page-root {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: var(--color-sb-bg);
}
/* ── Header ──────────────────────────────────────────── */
.chat-page-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 3rem;
padding: 0 1rem;
border-bottom: 1px solid var(--color-sb-border);
background: var(--color-sb-surface);
flex-shrink: 0;
}
.chat-page-logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-sb-text-bright);
text-decoration: none;
transition: opacity 0.15s;
}
.chat-page-logo:hover {
opacity: 0.8;
}
.chat-page-header-title {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-sb-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 50%;
text-align: center;
}
.chat-page-menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.375rem;
border: none;
background: transparent;
color: var(--color-sb-muted);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.chat-page-menu-btn:hover {
background: var(--color-sb-cell);
color: var(--color-sb-text);
}
/* ── Body (sidebar + main) ───────────────────────────── */
.chat-page-body {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Desktop sidebar ─────────────────────────────────── */
.chat-page-desktop-sidebar {
display: none;
width: 280px;
flex-shrink: 0;
border-right: 1px solid var(--color-sb-border);
background: var(--color-sb-surface);
}
@media (min-width: 768px) {
.chat-page-desktop-sidebar {
display: block;
}
}
/* ── Sidebar internals ───────────────────────────────── */
.chat-page-sidebar {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chat-page-sidebar-header {
padding: 0.75rem;
flex-shrink: 0;
}
.chat-page-new-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 1px dashed var(--color-sb-border);
background: transparent;
color: var(--color-sb-text);
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.1s, border-color 0.15s;
}
.chat-page-new-btn:hover {
background: var(--color-sb-cell);
border-color: var(--color-sb-accent);
color: var(--color-sb-text-bright);
}
.chat-page-sidebar-search {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0.75rem 0.5rem;
padding: 0.375rem 0.625rem;
border: 1px solid var(--color-sb-border);
border-radius: 0.375rem;
background: var(--color-sb-bg);
}
.chat-page-sidebar-search-input {
flex: 1;
border: none;
background: transparent;
color: var(--color-sb-text);
font-size: 0.8125rem;
outline: none;
font-family: var(--font-sans);
}
.chat-page-sidebar-search-input::placeholder {
color: var(--color-sb-muted);
}
/* ── Conversation list ───────────────────────────────── */
.chat-page-conv-list {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0 0.5rem;
}
.chat-page-conv-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid transparent;
background: transparent;
color: var(--color-sb-text);
text-align: left;
cursor: pointer;
transition: background 0.1s;
width: 100%;
}
.chat-page-conv-item:hover {
background: var(--color-sb-cell);
}
.chat-page-conv-item.active {
background: var(--color-sb-cell);
border-color: var(--color-sb-border);
}
.chat-page-conv-delete {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
color: var(--color-sb-muted);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.1s, color 0.1s;
}
.chat-page-conv-item:hover .chat-page-conv-delete {
opacity: 1;
}
.chat-page-conv-delete:hover {
color: var(--color-sb-danger);
}
.chat-page-conv-delete.confirm {
color: var(--color-sb-danger);
opacity: 1;
}
/* ── Notebook picker ─────────────────────────────────── */
.chat-page-notebook-picker {
padding: 0.75rem;
flex-shrink: 0;
}
.chat-page-notebook-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid var(--color-sb-border);
background: var(--color-sb-bg);
color: var(--color-sb-text);
font-size: 0.8125rem;
cursor: pointer;
transition: border-color 0.15s;
font-family: var(--font-sans);
}
.chat-page-notebook-btn:hover {
border-color: var(--color-sb-accent);
}
.chat-page-notebook-clear {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
color: var(--color-sb-muted);
transition: color 0.1s, background 0.1s;
}
.chat-page-notebook-clear:hover {
color: var(--color-sb-text-bright);
background: var(--color-sb-cell);
}
/* ── Main message area ───────────────────────────────── */
.chat-page-main {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
overflow: hidden;
}
/* ── Messages ────────────────────────────────────────── */
.chat-page-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.chat-page-messages::-webkit-scrollbar {
width: 6px;
}
.chat-page-messages::-webkit-scrollbar-thumb {
background: var(--color-sb-border);
border-radius: 3px;
}
.chat-page-messages::-webkit-scrollbar-thumb:hover {
background: var(--color-sb-muted);
}
/* Empty state */
.chat-page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 0.75rem;
padding: 2rem;
}
.chat-page-empty-icon {
color: var(--color-sb-border);
}
/* ── Message bubbles (wider than widget) ─────────────── */
.chat-page-bubble {
max-width: 720px;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
font-size: 0.9375rem;
line-height: 1.65;
word-wrap: break-word;
overflow-wrap: break-word;
}
.chat-page-bubble.user {
align-self: flex-end;
background: var(--color-sb-accent);
color: white;
border-bottom-right-radius: 0.25rem;
}
.chat-page-bubble.assistant {
align-self: flex-start;
background: var(--color-sb-cell);
color: var(--color-sb-text);
border-bottom-left-radius: 0.25rem;
}
/* ── Assistant bubble content styling ────────────────── */
.chat-page-bubble.assistant strong {
color: var(--color-sb-text-bright);
}
.chat-page-bubble.assistant code {
font-family: var(--font-mono);
font-size: 0.8125em;
background: var(--color-sb-code-bg);
padding: 0.125rem 0.375rem;
border-radius: 3px;
color: #93c5fd;
}
.chat-page-bubble.assistant pre {
background: var(--color-sb-code-bg);
border: 1px solid var(--color-sb-border);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
margin: 0.5rem 0;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 0.8125rem;
}
.chat-page-bubble.assistant pre code {
background: transparent;
padding: 0;
}
/* Headings */
.chat-page-bubble.assistant h1,
.chat-page-bubble.assistant h2,
.chat-page-bubble.assistant h3,
.chat-page-bubble.assistant h4 {
color: var(--color-sb-text-bright);
font-weight: 600;
margin: 0.875rem 0 0.375rem;
line-height: 1.3;
}
.chat-page-bubble.assistant h1 { font-size: 1.125rem; }
.chat-page-bubble.assistant h2 { font-size: 1.0625rem; }
.chat-page-bubble.assistant h3 { font-size: 1rem; }
.chat-page-bubble.assistant h4 { font-size: 0.9375rem; }
.chat-page-bubble.assistant h1:first-child,
.chat-page-bubble.assistant h2:first-child,
.chat-page-bubble.assistant h3:first-child,
.chat-page-bubble.assistant h4:first-child {
margin-top: 0;
}
/* Paragraphs */
.chat-page-bubble.assistant p {
margin: 0.5rem 0;
}
.chat-page-bubble.assistant p:first-child {
margin-top: 0;
}
.chat-page-bubble.assistant p:last-child {
margin-bottom: 0;
}
/* Lists */
.chat-page-bubble.assistant ul,
.chat-page-bubble.assistant ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.chat-page-bubble.assistant li {
margin: 0.25rem 0;
}
/* Horizontal rules */
.chat-page-bubble.assistant hr {
border: none;
border-top: 1px solid var(--color-sb-border);
margin: 0.75rem 0;
}
/* Blockquotes */
.chat-page-bubble.assistant blockquote {
border-left: 3px solid var(--color-sb-accent);
margin: 0.625rem 0;
padding: 0.5rem 0.75rem;
background: rgba(59, 130, 246, 0.08);
border-radius: 0 0.375rem 0.375rem 0;
color: var(--color-sb-text);
}
.chat-page-bubble.assistant blockquote p {
margin: 0.25rem 0;
}
/* Tables */
.chat-page-bubble.assistant table {
width: 100%;
border-collapse: collapse;
margin: 0.625rem 0;
font-size: 0.8125rem;
display: block;
overflow-x: auto;
}
.chat-page-bubble.assistant th,
.chat-page-bubble.assistant td {
padding: 0.375rem 0.625rem;
border: 1px solid var(--color-sb-border);
text-align: left;
}
.chat-page-bubble.assistant th {
background: var(--color-sb-surface);
color: var(--color-sb-text-bright);
font-weight: 600;
}
.chat-page-bubble.assistant tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
}
/* Links */
.chat-page-bubble.assistant a {
color: var(--color-sb-accent);
text-decoration: none;
}
.chat-page-bubble.assistant a:hover {
text-decoration: underline;
}
/* KaTeX */
.chat-page-bubble.assistant .katex {
font-size: 1em;
color: var(--color-sb-text-bright);
}
.chat-page-bubble.assistant .katex-display {
margin: 0.625rem 0;
overflow-x: auto;
overflow-y: hidden;
padding: 0.5rem 0;
}
.chat-page-bubble.assistant .katex-display > .katex {
white-space: normal;
}
/* ── Status + reasoning ──────────────────────────────── */
.chat-page-status {
text-align: center;
font-size: 0.75rem;
color: var(--color-sb-muted);
padding: 0.25rem 0;
font-style: italic;
}
.chat-page-reasoning {
font-size: 0.75rem;
color: var(--color-sb-muted);
margin-bottom: 0.25rem;
}
.chat-page-reasoning summary {
cursor: pointer;
color: var(--color-sb-muted);
font-size: 0.75rem;
}
.chat-page-reasoning summary:hover {
color: var(--color-sb-text);
}
.chat-page-reasoning-body {
margin-top: 0.25rem;
padding: 0.5rem 0.75rem;
background: var(--color-sb-surface);
border-radius: 0.5rem;
border: 1px solid var(--color-sb-border);
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* ── Streaming cursor ────────────────────────────────── */
.chat-page-cursor {
display: inline-block;
width: 2px;
height: 1em;
background: var(--color-sb-accent);
animation: chat-page-blink 1s step-end infinite;
vertical-align: text-bottom;
margin-left: 1px;
}
@keyframes chat-page-blink {
50% { opacity: 0; }
}
/* ── Input area ──────────────────────────────────────── */
.chat-page-input-container {
flex-shrink: 0;
padding: 0.75rem 1.5rem 1rem;
border-top: 1px solid var(--color-sb-border);
background: var(--color-sb-surface);
}
.chat-page-input-context {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.6875rem;
color: var(--color-sb-accent);
background: rgba(37, 99, 235, 0.1);
border-radius: 0.25rem;
width: fit-content;
}
.chat-page-input-row {
display: flex;
align-items: flex-end;
gap: 0.5rem;
}
.chat-page-textarea {
flex: 1;
background: var(--color-sb-bg);
border: 1px solid var(--color-sb-border);
border-radius: 0.5rem;
padding: 0.625rem 0.875rem;
font-size: 0.9375rem;
color: var(--color-sb-text);
outline: none;
font-family: var(--font-sans);
transition: border-color 0.15s;
resize: none;
line-height: 1.5;
min-height: 2.5rem;
max-height: 200px;
}
.chat-page-textarea::placeholder {
color: var(--color-sb-muted);
}
.chat-page-textarea:focus {
border-color: var(--color-sb-accent);
}
.chat-page-send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5rem;
border: none;
background: var(--color-sb-accent);
color: white;
cursor: pointer;
transition: background 0.15s;
flex-shrink: 0;
}
.chat-page-send-btn:hover:not(:disabled) {
background: var(--color-sb-accent-hover);
}
.chat-page-send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.chat-page-send-btn.abort {
background: var(--color-sb-danger);
}
.chat-page-send-btn.abort:hover {
background: #ef4444;
}
.chat-page-input-hint {
margin-top: 0.375rem;
font-size: 0.6875rem;
color: var(--color-sb-muted);
}
/* ── Responsive ──────────────────────────────────────── */
@media (max-width: 767px) {
.chat-page-messages {
padding: 1rem;
}
.chat-page-bubble {
max-width: 100%;
}
.chat-page-input-container {
padding: 0.625rem 1rem 0.75rem;
}
.chat-page-input-hint {
display: none;
}
}
/* Wider screens — center messages with max width */
@media (min-width: 1200px) {
.chat-page-messages {
padding-left: calc((100% - 800px) / 2);
padding-right: calc((100% - 800px) / 2);
}
}

View File

@ -32,6 +32,33 @@
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
}
/* shadcn-ui CSS variable mappings mapped to SpiceBook's dark slate theme.
These are consumed by Radix primitives and shadcn components.
We intentionally avoid re-declaring a full light/dark toggle since SpiceBook
is dark-only we just wire the shadcn variable names to our tokens. */
:root {
--background: var(--color-sb-bg);
--foreground: var(--color-sb-text);
--card: var(--color-sb-surface);
--card-foreground: var(--color-sb-text);
--popover: var(--color-sb-surface);
--popover-foreground: var(--color-sb-text);
--primary: var(--color-sb-accent);
--primary-foreground: #ffffff;
--secondary: var(--color-sb-cell);
--secondary-foreground: var(--color-sb-text);
--muted: var(--color-sb-cell);
--muted-foreground: var(--color-sb-muted);
--accent: var(--color-sb-cell);
--accent-foreground: var(--color-sb-text-bright);
--destructive: var(--color-sb-danger);
--destructive-foreground: #ffffff;
--border: var(--color-sb-border);
--input: var(--color-sb-border);
--ring: var(--color-sb-accent);
--radius: 0.5rem;
}
/* Waveform canvas colors (read by JS via getComputedStyle) */
:root {
--color-wf-axis: #475569;