SPB Git

spb/zyquo-cloud-web Public MIT

Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.

TypeScript 81.9% CSS 8.9% JavaScript 7.5% Shell 1.1% HTML 0.6%

phase4: full chat UI — shell, sidebar, transcript, model menu, input bar, settings, first-run; responsive + light/dark verified

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 10 days ago (Jul 31, 2026) parent 72bdedc

Showing 22 changed files with +6,539 and −69

modified docs/PLAN.md +19 −1
@@ -92,7 +92,25 @@ with WebCrypto AES-GCM and is honestly framed. Gate ran in a real browser with
92 92 a persistent profile: reload AND restart survival, exact export round-trip, and
93 93 vault behavior all green.
94 94
95 ## Phase 4 — Design system & UI — pending
95 +## Phase 4 — Design system & UI
96 +
97 +- [x] 4.1 Light flagship theme via tokens (Phase 1 CSS vars) — light default, dark opt-in, 5 accents, reduced-motion respected
98 +- [x] 4.2 Shell: sidebar (wordmark, search ⌘F, New Chat ⌘N, Pinned/Today/Yesterday/7 Days/Older groups, hover pin/delete, usage footer) + chat area (header 52px with editable title, centered model chip, compare/export/info actions)
99 +- [x] 4.2.1 Model Menu: ⌘K command-palette style — fuzzy search, capability filter chips (Vision/Tools/Reasoning/JSON/Long context/Cheapest), Aliases + Favorites + Recents groups, keyed-providers-first sections, capability/context/price badges, star favorites, "Add key" affordance, keyboard nav (↑↓/↵/⌘↵ default/esc), footer hints
100 +- [x] Transcript: centered 760px column, user right accent-subtle / assistant left surface+avatar, GFM markdown + tables + zebra rows + syntax-highlighted code (language label, copy, wrap), collapsible reasoning, citation chips, streaming caret, scroll-lock + jump-to-latest pill, hover metadata (time, tokens, cost) + actions (copy/edit-resend/regenerate/quote/branch/read-aloud/delete), variant switcher
101 +- [x] Input bar: floating card, auto-grow, image+text-file attach (click/drag/paste), thumbnails, params popover (support-gated controls with enable checkboxes), circular send ⌘↩ / Stop, context-usage bar + token/cost HUD
102 +- [x] Settings modal: Providers & Keys (masked, Test + latency + status dot, per-provider proxy URL, honesty notice), Models (catalog browser, favorites, set-default), Appearance (theme/accent/font-size/density + live preview), Data (export incl.-keys warning / import / confirm-twice clear), Advanced (default system prompt, passphrase lock, streaming toggle)
103 +- [x] First-run honest welcome + root empty state (glyph, greeting, model chip, 4 suggestion cards — self-contained ones send immediately, input-expecting ones seed the draft)
104 +- [x] Toasts with undo (conversation delete)
105 +- [x] 4.3 Responsive: sidebar drawer <768px with scrim, full-width transcript, code blocks scroll internally, no horizontal overflow; motion tokens (80ms hover, 150ms message-in, blinking caret)
106 +- [x] 4.4 Quality gate: screenshotted desktop+phone, light+dark, model menu, settings — reviewed and fixed (filter-chip clipping, cost precision, draft seeding)
107 +
108 +**Phase 4 summary.** The full chat UI is live and matches the family design:
109 +tokens-only styling, light flagship default, dark derived, responsive down to
110 +phone width with a drawer sidebar. The Model Menu is the headline surface —
111 +fuzzy-searchable, filterable, priced, favorited, keyboard-driven. Verified
112 +visually in headless Chromium at 1240px and 390px in both themes; found and
113 +fixed three issues in the review pass. `npm run check` green.
96 114
97 115 ## Phase 5 — Brand assets — pending
98 116
modified package-lock.json +1599 −49
@@ -8,8 +8,12 @@
8 8 "name": "zyquo-cloud-web",
9 9 "version": "1.0.0",
10 10 "dependencies": {
11 + "highlight.js": "^11.11.1",
11 12 "react": "^19.1.0",
12 13 "react-dom": "^19.1.0",
14 + "react-markdown": "^10.1.0",
15 + "rehype-highlight": "^7.0.2",
16 + "remark-gfm": "^4.0.1",
13 17 "zustand": "^5.0.6"
14 18 },
15 19 "devDependencies": {
@@ -3011,13 +3015,39 @@
3011 3015 "@babel/types": "^7.28.2"
3012 3016 }
3013 3017 },
3018 + "node_modules/@types/debug": {
3019 + "version": "4.1.13",
3020 + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
3021 + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
3022 + "license": "MIT",
3023 + "dependencies": {
3024 + "@types/ms": "*"
3025 + }
3026 + },
3014 3027 "node_modules/@types/estree": {
3015 3028 "version": "1.0.9",
3016 3029 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
3017 3030 "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
3018 "dev": true,
3019 3031 "license": "MIT"
3020 3032 },
3033 + "node_modules/@types/estree-jsx": {
3034 + "version": "1.0.5",
3035 + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
3036 + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
3037 + "license": "MIT",
3038 + "dependencies": {
3039 + "@types/estree": "*"
3040 + }
3041 + },
3042 + "node_modules/@types/hast": {
3043 + "version": "3.0.5",
3044 + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
3045 + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
3046 + "license": "MIT",
3047 + "dependencies": {
3048 + "@types/unist": "*"
3049 + }
3050 + },
3021 3051 "node_modules/@types/json-schema": {
3022 3052 "version": "7.0.15",
3023 3053 "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3025,11 +3055,25 @@
3025 3055 "dev": true,
3026 3056 "license": "MIT"
3027 3057 },
3058 + "node_modules/@types/mdast": {
3059 + "version": "4.0.4",
3060 + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
3061 + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
3062 + "license": "MIT",
3063 + "dependencies": {
3064 + "@types/unist": "*"
3065 + }
3066 + },
3067 + "node_modules/@types/ms": {
3068 + "version": "2.1.0",
3069 + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
3070 + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
3071 + "license": "MIT"
3072 + },
3028 3073 "node_modules/@types/react": {
3029 3074 "version": "19.2.18",
3030 3075 "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
3031 3076 "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
3032 "devOptional": true,
3033 3077 "license": "MIT",
3034 3078 "dependencies": {
3035 3079 "csstype": "^3.2.2"
@@ -3059,6 +3103,12 @@
3059 3103 "dev": true,
3060 3104 "license": "MIT"
3061 3105 },
3106 + "node_modules/@types/unist": {
3107 + "version": "3.0.3",
3108 + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
3109 + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
3110 + "license": "MIT"
3111 + },
3062 3112 "node_modules/@typescript-eslint/eslint-plugin": {
3063 3113 "version": "8.65.0",
3064 3114 "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
@@ -3292,6 +3342,12 @@
3292 3342 "url": "https://opencollective.com/eslint"
3293 3343 }
3294 3344 },
3345 + "node_modules/@ungap/structured-clone": {
3346 + "version": "1.3.3",
3347 + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
3348 + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==",
3349 + "license": "ISC"
3350 + },
3295 3351 "node_modules/@vitejs/plugin-react": {
3296 3352 "version": "4.7.0",
3297 3353 "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -3510,6 +3566,16 @@
3510 3566 "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
3511 3567 }
3512 3568 },
3569 + "node_modules/bail": {
3570 + "version": "2.0.2",
3571 + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
3572 + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
3573 + "license": "MIT",
3574 + "funding": {
3575 + "type": "github",
3576 + "url": "https://github.com/sponsors/wooorm"
3577 + }
3578 + },
3513 3579 "node_modules/balanced-match": {
3514 3580 "version": "4.0.4",
3515 3581 "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -3668,6 +3734,16 @@
3668 3734 ],
3669 3735 "license": "CC-BY-4.0"
3670 3736 },
3737 + "node_modules/ccount": {
3738 + "version": "2.0.1",
3739 + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
3740 + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
3741 + "license": "MIT",
3742 + "funding": {
3743 + "type": "github",
3744 + "url": "https://github.com/sponsors/wooorm"
3745 + }
3746 + },
3671 3747 "node_modules/chalk": {
3672 3748 "version": "4.1.2",
3673 3749 "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3685,6 +3761,46 @@
3685 3761 "url": "https://github.com/chalk/chalk?sponsor=1"
3686 3762 }
3687 3763 },
3764 + "node_modules/character-entities": {
3765 + "version": "2.0.2",
3766 + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
3767 + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
3768 + "license": "MIT",
3769 + "funding": {
3770 + "type": "github",
3771 + "url": "https://github.com/sponsors/wooorm"
3772 + }
3773 + },
3774 + "node_modules/character-entities-html4": {
3775 + "version": "2.1.0",
3776 + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
3777 + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
3778 + "license": "MIT",
3779 + "funding": {
3780 + "type": "github",
3781 + "url": "https://github.com/sponsors/wooorm"
3782 + }
3783 + },
3784 + "node_modules/character-entities-legacy": {
3785 + "version": "3.0.0",
3786 + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
3787 + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
3788 + "license": "MIT",
3789 + "funding": {
3790 + "type": "github",
3791 + "url": "https://github.com/sponsors/wooorm"
3792 + }
3793 + },
3794 + "node_modules/character-reference-invalid": {
3795 + "version": "2.0.1",
3796 + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
3797 + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
3798 + "license": "MIT",
3799 + "funding": {
3800 + "type": "github",
3801 + "url": "https://github.com/sponsors/wooorm"
3802 + }
3803 + },
3688 3804 "node_modules/color-convert": {
3689 3805 "version": "2.0.1",
3690 3806 "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3705,6 +3821,16 @@
3705 3821 "dev": true,
3706 3822 "license": "MIT"
3707 3823 },
3824 + "node_modules/comma-separated-tokens": {
3825 + "version": "2.0.3",
3826 + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
3827 + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
3828 + "license": "MIT",
3829 + "funding": {
3830 + "type": "github",
3831 + "url": "https://github.com/sponsors/wooorm"
3832 + }
3833 + },
3708 3834 "node_modules/commander": {
3709 3835 "version": "2.20.3",
3710 3836 "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -3779,7 +3905,6 @@
3779 3905 "version": "3.2.3",
3780 3906 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
3781 3907 "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
3782 "devOptional": true,
3783 3908 "license": "MIT"
3784 3909 },
3785 3910 "node_modules/data-view-buffer": {
@@ -3840,7 +3965,6 @@
3840 3965 "version": "4.4.3",
3841 3966 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
3842 3967 "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
3843 "dev": true,
3844 3968 "license": "MIT",
3845 3969 "dependencies": {
3846 3970 "ms": "^2.1.3"
@@ -3854,6 +3978,19 @@
3854 3978 }
3855 3979 }
3856 3980 },
3981 + "node_modules/decode-named-character-reference": {
3982 + "version": "1.3.0",
3983 + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
3984 + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
3985 + "license": "MIT",
3986 + "dependencies": {
3987 + "character-entities": "^2.0.0"
3988 + },
3989 + "funding": {
3990 + "type": "github",
3991 + "url": "https://github.com/sponsors/wooorm"
3992 + }
3993 + },
3857 3994 "node_modules/deep-is": {
3858 3995 "version": "0.1.4",
3859 3996 "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3907,6 +4044,28 @@
3907 4044 "url": "https://github.com/sponsors/ljharb"
3908 4045 }
3909 4046 },
4047 + "node_modules/dequal": {
4048 + "version": "2.0.3",
4049 + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
4050 + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
4051 + "license": "MIT",
4052 + "engines": {
4053 + "node": ">=6"
4054 + }
4055 + },
4056 + "node_modules/devlop": {
4057 + "version": "1.1.0",
4058 + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
4059 + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
4060 + "license": "MIT",
4061 + "dependencies": {
4062 + "dequal": "^2.0.0"
4063 + },
4064 + "funding": {
4065 + "type": "github",
4066 + "url": "https://github.com/sponsors/wooorm"
4067 + }
4068 + },
3910 4069 "node_modules/dunder-proto": {
3911 4070 "version": "1.0.1",
3912 4071 "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4392,6 +4551,16 @@
4392 4551 "node": ">=4.0"
4393 4552 }
4394 4553 },
4554 + "node_modules/estree-util-is-identifier-name": {
4555 + "version": "3.0.0",
4556 + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
4557 + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
4558 + "license": "MIT",
4559 + "funding": {
4560 + "type": "opencollective",
4561 + "url": "https://opencollective.com/unified"
4562 + }
4563 + },
4395 4564 "node_modules/estree-walker": {
4396 4565 "version": "2.0.2",
4397 4566 "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
@@ -4422,6 +4591,12 @@
4422 4591 "url": "https://github.com/bgub/eta?sponsor=1"
4423 4592 }
4424 4593 },
4594 + "node_modules/extend": {
4595 + "version": "3.0.2",
4596 + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
4597 + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
4598 + "license": "MIT"
4599 + },
4425 4600 "node_modules/fast-deep-equal": {
4426 4601 "version": "3.1.3",
4427 4602 "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4943,6 +5118,94 @@
4943 5118 "node": ">= 0.4"
4944 5119 }
4945 5120 },
5121 + "node_modules/hast-util-is-element": {
5122 + "version": "3.0.0",
5123 + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
5124 + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
5125 + "license": "MIT",
5126 + "dependencies": {
5127 + "@types/hast": "^3.0.0"
5128 + },
5129 + "funding": {
5130 + "type": "opencollective",
5131 + "url": "https://opencollective.com/unified"
5132 + }
5133 + },
5134 + "node_modules/hast-util-to-jsx-runtime": {
5135 + "version": "2.3.6",
5136 + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
5137 + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
5138 + "license": "MIT",
5139 + "dependencies": {
5140 + "@types/estree": "^1.0.0",
5141 + "@types/hast": "^3.0.0",
5142 + "@types/unist": "^3.0.0",
5143 + "comma-separated-tokens": "^2.0.0",
5144 + "devlop": "^1.0.0",
5145 + "estree-util-is-identifier-name": "^3.0.0",
5146 + "hast-util-whitespace": "^3.0.0",
5147 + "mdast-util-mdx-expression": "^2.0.0",
5148 + "mdast-util-mdx-jsx": "^3.0.0",
5149 + "mdast-util-mdxjs-esm": "^2.0.0",
5150 + "property-information": "^7.0.0",
5151 + "space-separated-tokens": "^2.0.0",
5152 + "style-to-js": "^1.0.0",
5153 + "unist-util-position": "^5.0.0",
5154 + "vfile-message": "^4.0.0"
5155 + },
5156 + "funding": {
5157 + "type": "opencollective",
5158 + "url": "https://opencollective.com/unified"
5159 + }
5160 + },
5161 + "node_modules/hast-util-to-text": {
5162 + "version": "4.0.2",
5163 + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
5164 + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
5165 + "license": "MIT",
5166 + "dependencies": {
5167 + "@types/hast": "^3.0.0",
5168 + "@types/unist": "^3.0.0",
5169 + "hast-util-is-element": "^3.0.0",
5170 + "unist-util-find-after": "^5.0.0"
5171 + },
5172 + "funding": {
5173 + "type": "opencollective",
5174 + "url": "https://opencollective.com/unified"
5175 + }
5176 + },
5177 + "node_modules/hast-util-whitespace": {
5178 + "version": "3.0.0",
5179 + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
5180 + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
5181 + "license": "MIT",
5182 + "dependencies": {
5183 + "@types/hast": "^3.0.0"
5184 + },
5185 + "funding": {
5186 + "type": "opencollective",
5187 + "url": "https://opencollective.com/unified"
5188 + }
5189 + },
5190 + "node_modules/highlight.js": {
5191 + "version": "11.11.1",
5192 + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
5193 + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
5194 + "license": "BSD-3-Clause",
5195 + "engines": {
5196 + "node": ">=12.0.0"
5197 + }
5198 + },
5199 + "node_modules/html-url-attributes": {
5200 + "version": "3.0.1",
5201 + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
5202 + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
5203 + "license": "MIT",
5204 + "funding": {
5205 + "type": "opencollective",
5206 + "url": "https://opencollective.com/unified"
5207 + }
5208 + },
4946 5209 "node_modules/idb": {
4947 5210 "version": "7.1.1",
4948 5211 "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@@ -4987,6 +5250,12 @@
4987 5250 "node": ">=0.8.19"
4988 5251 }
4989 5252 },
5253 + "node_modules/inline-style-parser": {
5254 + "version": "0.2.7",
5255 + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
5256 + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
5257 + "license": "MIT"
5258 + },
4990 5259 "node_modules/internal-slot": {
4991 5260 "version": "1.1.0",
4992 5261 "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5002,6 +5271,30 @@
5002 5271 "node": ">= 0.4"
5003 5272 }
5004 5273 },
5274 + "node_modules/is-alphabetical": {
5275 + "version": "2.0.1",
5276 + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
5277 + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
5278 + "license": "MIT",
5279 + "funding": {
5280 + "type": "github",
5281 + "url": "https://github.com/sponsors/wooorm"
5282 + }
5283 + },
5284 + "node_modules/is-alphanumerical": {
5285 + "version": "2.0.1",
5286 + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
5287 + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
5288 + "license": "MIT",
5289 + "dependencies": {
5290 + "is-alphabetical": "^2.0.0",
5291 + "is-decimal": "^2.0.0"
5292 + },
5293 + "funding": {
5294 + "type": "github",
5295 + "url": "https://github.com/sponsors/wooorm"
5296 + }
5297 + },
5005 5298 "node_modules/is-array-buffer": {
5006 5299 "version": "3.0.5",
5007 5300 "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -5137,6 +5430,16 @@
5137 5430 "url": "https://github.com/sponsors/ljharb"
5138 5431 }
5139 5432 },
5433 + "node_modules/is-decimal": {
5434 + "version": "2.0.1",
5435 + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
5436 + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
5437 + "license": "MIT",
5438 + "funding": {
5439 + "type": "github",
5440 + "url": "https://github.com/sponsors/wooorm"
5441 + }
5442 + },
5140 5443 "node_modules/is-document.all": {
5141 5444 "version": "1.0.0",
5142 5445 "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz",
@@ -5212,6 +5515,16 @@
5212 5515 "node": ">=0.10.0"
5213 5516 }
5214 5517 },
5518 + "node_modules/is-hexadecimal": {
5519 + "version": "2.0.1",
5520 + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
5521 + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
5522 + "license": "MIT",
5523 + "funding": {
5524 + "type": "github",
5525 + "url": "https://github.com/sponsors/wooorm"
5526 + }
5527 + },
5215 5528 "node_modules/is-map": {
5216 5529 "version": "2.0.3",
5217 5530 "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
@@ -5272,6 +5585,18 @@
5272 5585 "node": ">=0.10.0"
5273 5586 }
5274 5587 },
5588 + "node_modules/is-plain-obj": {
5589 + "version": "4.1.0",
5590 + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
5591 + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
5592 + "license": "MIT",
5593 + "engines": {
5594 + "node": ">=12"
5595 + },
5596 + "funding": {
5597 + "url": "https://github.com/sponsors/sindresorhus"
5598 + }
5599 + },
5275 5600 "node_modules/is-regex": {
5276 5601 "version": "1.2.1",
5277 5602 "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -5652,6 +5977,31 @@
5652 5977 "dev": true,
5653 5978 "license": "MIT"
5654 5979 },
5980 + "node_modules/longest-streak": {
5981 + "version": "3.1.0",
5982 + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
5983 + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
5984 + "license": "MIT",
5985 + "funding": {
5986 + "type": "github",
5987 + "url": "https://github.com/sponsors/wooorm"
5988 + }
5989 + },
5990 + "node_modules/lowlight": {
5991 + "version": "3.3.0",
5992 + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
5993 + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
5994 + "license": "MIT",
5995 + "dependencies": {
5996 + "@types/hast": "^3.0.0",
5997 + "devlop": "^1.0.0",
5998 + "highlight.js": "~11.11.0"
5999 + },
6000 + "funding": {
6001 + "type": "github",
6002 + "url": "https://github.com/sponsors/wooorm"
6003 + }
6004 + },
5655 6005 "node_modules/lru-cache": {
5656 6006 "version": "5.1.1",
5657 6007 "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -5672,6 +6022,16 @@
5672 6022 "@jridgewell/sourcemap-codec": "^1.5.5"
5673 6023 }
5674 6024 },
6025 + "node_modules/markdown-table": {
6026 + "version": "3.0.4",
6027 + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
6028 + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
6029 + "license": "MIT",
6030 + "funding": {
6031 + "type": "github",
6032 + "url": "https://github.com/sponsors/wooorm"
6033 + }
6034 + },
5675 6035 "node_modules/math-intrinsics": {
5676 6036 "version": "1.1.0",
5677 6037 "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5682,63 +6042,907 @@
5682 6042 "node": ">= 0.4"
5683 6043 }
5684 6044 },
5685 "node_modules/minimatch": {
5686 "version": "10.2.6",
5687 "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
5688 "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
5689 "dev": true,
5690 "license": "BlueOak-1.0.0",
6045 + "node_modules/mdast-util-find-and-replace": {
6046 + "version": "3.0.2",
6047 + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
6048 + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
6049 + "license": "MIT",
5691 6050 "dependencies": {
5692 "brace-expansion": "^5.0.8"
5693 },
5694 "engines": {
5695 "node": "18 || 20 || >=22"
6051 + "@types/mdast": "^4.0.0",
6052 + "escape-string-regexp": "^5.0.0",
6053 + "unist-util-is": "^6.0.0",
6054 + "unist-util-visit-parents": "^6.0.0"
5696 6055 },
5697 6056 "funding": {
5698 "url": "https://github.com/sponsors/isaacs"
6057 + "type": "opencollective",
6058 + "url": "https://opencollective.com/unified"
5699 6059 }
5700 6060 },
5701 "node_modules/minipass": {
5702 "version": "7.1.3",
5703 "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
5704 "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
5705 "dev": true,
5706 "license": "BlueOak-1.0.0",
6061 + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
6062 + "version": "5.0.0",
6063 + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
6064 + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
6065 + "license": "MIT",
5707 6066 "engines": {
5708 "node": ">=16 || 14 >=14.17"
6067 + "node": ">=12"
6068 + },
6069 + "funding": {
6070 + "url": "https://github.com/sponsors/sindresorhus"
5709 6071 }
5710 6072 },
5711 "node_modules/ms": {
5712 "version": "2.1.3",
5713 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
5714 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
5715 "dev": true,
5716 "license": "MIT"
5717 },
5718 "node_modules/nanoid": {
5719 "version": "3.3.16",
5720 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
5721 "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
5722 "dev": true,
5723 "funding": [
5724 {
5725 "type": "github",
5726 "url": "https://github.com/sponsors/ai"
5727 }
5728 ],
6073 + "node_modules/mdast-util-from-markdown": {
6074 + "version": "2.0.3",
6075 + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
6076 + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
5729 6077 "license": "MIT",
5730 "bin": {
5731 "nanoid": "bin/nanoid.cjs"
6078 + "dependencies": {
6079 + "@types/mdast": "^4.0.0",
6080 + "@types/unist": "^3.0.0",
6081 + "decode-named-character-reference": "^1.0.0",
6082 + "devlop": "^1.0.0",
6083 + "mdast-util-to-string": "^4.0.0",
6084 + "micromark": "^4.0.0",
6085 + "micromark-util-decode-numeric-character-reference": "^2.0.0",
6086 + "micromark-util-decode-string": "^2.0.0",
6087 + "micromark-util-normalize-identifier": "^2.0.0",
6088 + "micromark-util-symbol": "^2.0.0",
6089 + "micromark-util-types": "^2.0.0",
6090 + "unist-util-stringify-position": "^4.0.0"
5732 6091 },
5733 "engines": {
5734 "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
6092 + "funding": {
6093 + "type": "opencollective",
6094 + "url": "https://opencollective.com/unified"
5735 6095 }
5736 6096 },
5737 "node_modules/natural-compare": {
5738 "version": "1.4.0",
5739 "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
5740 "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
5741 "dev": true,
6097 + "node_modules/mdast-util-gfm": {
6098 + "version": "3.1.0",
6099 + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
6100 + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
6101 + "license": "MIT",
6102 + "dependencies": {
6103 + "mdast-util-from-markdown": "^2.0.0",
6104 + "mdast-util-gfm-autolink-literal": "^2.0.0",
6105 + "mdast-util-gfm-footnote": "^2.0.0",
6106 + "mdast-util-gfm-strikethrough": "^2.0.0",
6107 + "mdast-util-gfm-table": "^2.0.0",
6108 + "mdast-util-gfm-task-list-item": "^2.0.0",
6109 + "mdast-util-to-markdown": "^2.0.0"
6110 + },
6111 + "funding": {
6112 + "type": "opencollective",
6113 + "url": "https://opencollective.com/unified"
6114 + }
6115 + },
6116 + "node_modules/mdast-util-gfm-autolink-literal": {
6117 + "version": "2.0.1",
6118 + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
6119 + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
6120 + "license": "MIT",
6121 + "dependencies": {
6122 + "@types/mdast": "^4.0.0",
6123 + "ccount": "^2.0.0",
6124 + "devlop": "^1.0.0",
6125 + "mdast-util-find-and-replace": "^3.0.0",
6126 + "micromark-util-character": "^2.0.0"
6127 + },
6128 + "funding": {
6129 + "type": "opencollective",
6130 + "url": "https://opencollective.com/unified"
6131 + }
6132 + },
6133 + "node_modules/mdast-util-gfm-footnote": {
6134 + "version": "2.1.0",
6135 + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
6136 + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
6137 + "license": "MIT",
6138 + "dependencies": {
6139 + "@types/mdast": "^4.0.0",
6140 + "devlop": "^1.1.0",
6141 + "mdast-util-from-markdown": "^2.0.0",
6142 + "mdast-util-to-markdown": "^2.0.0",
6143 + "micromark-util-normalize-identifier": "^2.0.0"
6144 + },
6145 + "funding": {
6146 + "type": "opencollective",
6147 + "url": "https://opencollective.com/unified"
6148 + }
6149 + },
6150 + "node_modules/mdast-util-gfm-strikethrough": {
6151 + "version": "2.0.0",
6152 + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
6153 + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
6154 + "license": "MIT",
6155 + "dependencies": {
6156 + "@types/mdast": "^4.0.0",
6157 + "mdast-util-from-markdown": "^2.0.0",
6158 + "mdast-util-to-markdown": "^2.0.0"
6159 + },
6160 + "funding": {
6161 + "type": "opencollective",
6162 + "url": "https://opencollective.com/unified"
6163 + }
6164 + },
6165 + "node_modules/mdast-util-gfm-table": {
6166 + "version": "2.0.0",
6167 + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
6168 + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
6169 + "license": "MIT",
6170 + "dependencies": {
6171 + "@types/mdast": "^4.0.0",
6172 + "devlop": "^1.0.0",
6173 + "markdown-table": "^3.0.0",
6174 + "mdast-util-from-markdown": "^2.0.0",
6175 + "mdast-util-to-markdown": "^2.0.0"
6176 + },
6177 + "funding": {
6178 + "type": "opencollective",
6179 + "url": "https://opencollective.com/unified"
6180 + }
6181 + },
6182 + "node_modules/mdast-util-gfm-task-list-item": {
6183 + "version": "2.0.0",
6184 + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
6185 + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
6186 + "license": "MIT",
6187 + "dependencies": {
6188 + "@types/mdast": "^4.0.0",
6189 + "devlop": "^1.0.0",
6190 + "mdast-util-from-markdown": "^2.0.0",
6191 + "mdast-util-to-markdown": "^2.0.0"
6192 + },
6193 + "funding": {
6194 + "type": "opencollective",
6195 + "url": "https://opencollective.com/unified"
6196 + }
6197 + },
6198 + "node_modules/mdast-util-mdx-expression": {
6199 + "version": "2.0.1",
6200 + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
6201 + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
6202 + "license": "MIT",
6203 + "dependencies": {
6204 + "@types/estree-jsx": "^1.0.0",
6205 + "@types/hast": "^3.0.0",
6206 + "@types/mdast": "^4.0.0",
6207 + "devlop": "^1.0.0",
6208 + "mdast-util-from-markdown": "^2.0.0",
6209 + "mdast-util-to-markdown": "^2.0.0"
6210 + },
6211 + "funding": {
6212 + "type": "opencollective",
6213 + "url": "https://opencollective.com/unified"
6214 + }
6215 + },
6216 + "node_modules/mdast-util-mdx-jsx": {
6217 + "version": "3.2.0",
6218 + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
6219 + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
6220 + "license": "MIT",
6221 + "dependencies": {
6222 + "@types/estree-jsx": "^1.0.0",
6223 + "@types/hast": "^3.0.0",
6224 + "@types/mdast": "^4.0.0",
6225 + "@types/unist": "^3.0.0",
6226 + "ccount": "^2.0.0",
6227 + "devlop": "^1.1.0",
6228 + "mdast-util-from-markdown": "^2.0.0",
6229 + "mdast-util-to-markdown": "^2.0.0",
6230 + "parse-entities": "^4.0.0",
6231 + "stringify-entities": "^4.0.0",
6232 + "unist-util-stringify-position": "^4.0.0",
6233 + "vfile-message": "^4.0.0"
6234 + },
6235 + "funding": {
6236 + "type": "opencollective",
6237 + "url": "https://opencollective.com/unified"
6238 + }
6239 + },
6240 + "node_modules/mdast-util-mdxjs-esm": {
6241 + "version": "2.0.1",
6242 + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
6243 + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
6244 + "license": "MIT",
6245 + "dependencies": {
6246 + "@types/estree-jsx": "^1.0.0",
6247 + "@types/hast": "^3.0.0",
6248 + "@types/mdast": "^4.0.0",
6249 + "devlop": "^1.0.0",
6250 + "mdast-util-from-markdown": "^2.0.0",
6251 + "mdast-util-to-markdown": "^2.0.0"
6252 + },
6253 + "funding": {
6254 + "type": "opencollective",
6255 + "url": "https://opencollective.com/unified"
6256 + }
6257 + },
6258 + "node_modules/mdast-util-phrasing": {
6259 + "version": "4.1.0",
6260 + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
6261 + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
6262 + "license": "MIT",
6263 + "dependencies": {
6264 + "@types/mdast": "^4.0.0",
6265 + "unist-util-is": "^6.0.0"
6266 + },
6267 + "funding": {
6268 + "type": "opencollective",
6269 + "url": "https://opencollective.com/unified"
6270 + }
6271 + },
6272 + "node_modules/mdast-util-to-hast": {
6273 + "version": "13.2.1",
6274 + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
6275 + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
6276 + "license": "MIT",
6277 + "dependencies": {
6278 + "@types/hast": "^3.0.0",
6279 + "@types/mdast": "^4.0.0",
6280 + "@ungap/structured-clone": "^1.0.0",
6281 + "devlop": "^1.0.0",
6282 + "micromark-util-sanitize-uri": "^2.0.0",
6283 + "trim-lines": "^3.0.0",
6284 + "unist-util-position": "^5.0.0",
6285 + "unist-util-visit": "^5.0.0",
6286 + "vfile": "^6.0.0"
6287 + },
6288 + "funding": {
6289 + "type": "opencollective",
6290 + "url": "https://opencollective.com/unified"
6291 + }
6292 + },
6293 + "node_modules/mdast-util-to-markdown": {
6294 + "version": "2.1.2",
6295 + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
6296 + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
6297 + "license": "MIT",
6298 + "dependencies": {
6299 + "@types/mdast": "^4.0.0",
6300 + "@types/unist": "^3.0.0",
6301 + "longest-streak": "^3.0.0",
6302 + "mdast-util-phrasing": "^4.0.0",
6303 + "mdast-util-to-string": "^4.0.0",
6304 + "micromark-util-classify-character": "^2.0.0",
6305 + "micromark-util-decode-string": "^2.0.0",
6306 + "unist-util-visit": "^5.0.0",
6307 + "zwitch": "^2.0.0"
6308 + },
6309 + "funding": {
6310 + "type": "opencollective",
6311 + "url": "https://opencollective.com/unified"
6312 + }
6313 + },
6314 + "node_modules/mdast-util-to-string": {
6315 + "version": "4.0.0",
6316 + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
6317 + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
6318 + "license": "MIT",
6319 + "dependencies": {
6320 + "@types/mdast": "^4.0.0"
6321 + },
6322 + "funding": {
6323 + "type": "opencollective",
6324 + "url": "https://opencollective.com/unified"
6325 + }
6326 + },
6327 + "node_modules/micromark": {
6328 + "version": "4.0.2",
6329 + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
6330 + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
6331 + "funding": [
6332 + {
6333 + "type": "GitHub Sponsors",
6334 + "url": "https://github.com/sponsors/unifiedjs"
6335 + },
6336 + {
6337 + "type": "OpenCollective",
6338 + "url": "https://opencollective.com/unified"
6339 + }
6340 + ],
6341 + "license": "MIT",
6342 + "dependencies": {
6343 + "@types/debug": "^4.0.0",
6344 + "debug": "^4.0.0",
6345 + "decode-named-character-reference": "^1.0.0",
6346 + "devlop": "^1.0.0",
6347 + "micromark-core-commonmark": "^2.0.0",
6348 + "micromark-factory-space": "^2.0.0",
6349 + "micromark-util-character": "^2.0.0",
6350 + "micromark-util-chunked": "^2.0.0",
6351 + "micromark-util-combine-extensions": "^2.0.0",
6352 + "micromark-util-decode-numeric-character-reference": "^2.0.0",
6353 + "micromark-util-encode": "^2.0.0",
6354 + "micromark-util-normalize-identifier": "^2.0.0",
6355 + "micromark-util-resolve-all": "^2.0.0",
6356 + "micromark-util-sanitize-uri": "^2.0.0",
6357 + "micromark-util-subtokenize": "^2.0.0",
6358 + "micromark-util-symbol": "^2.0.0",
6359 + "micromark-util-types": "^2.0.0"
6360 + }
6361 + },
6362 + "node_modules/micromark-core-commonmark": {
6363 + "version": "2.0.3",
6364 + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
6365 + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
6366 + "funding": [
6367 + {
6368 + "type": "GitHub Sponsors",
6369 + "url": "https://github.com/sponsors/unifiedjs"
6370 + },
6371 + {
6372 + "type": "OpenCollective",
6373 + "url": "https://opencollective.com/unified"
6374 + }
6375 + ],
6376 + "license": "MIT",
6377 + "dependencies": {
6378 + "decode-named-character-reference": "^1.0.0",
6379 + "devlop": "^1.0.0",
6380 + "micromark-factory-destination": "^2.0.0",
6381 + "micromark-factory-label": "^2.0.0",
6382 + "micromark-factory-space": "^2.0.0",
6383 + "micromark-factory-title": "^2.0.0",
6384 + "micromark-factory-whitespace": "^2.0.0",
6385 + "micromark-util-character": "^2.0.0",
6386 + "micromark-util-chunked": "^2.0.0",
6387 + "micromark-util-classify-character": "^2.0.0",
6388 + "micromark-util-html-tag-name": "^2.0.0",
6389 + "micromark-util-normalize-identifier": "^2.0.0",
6390 + "micromark-util-resolve-all": "^2.0.0",
6391 + "micromark-util-subtokenize": "^2.0.0",
6392 + "micromark-util-symbol": "^2.0.0",
6393 + "micromark-util-types": "^2.0.0"
6394 + }
6395 + },
6396 + "node_modules/micromark-extension-gfm": {
6397 + "version": "3.0.0",
6398 + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
6399 + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
6400 + "license": "MIT",
6401 + "dependencies": {
6402 + "micromark-extension-gfm-autolink-literal": "^2.0.0",
6403 + "micromark-extension-gfm-footnote": "^2.0.0",
6404 + "micromark-extension-gfm-strikethrough": "^2.0.0",
6405 + "micromark-extension-gfm-table": "^2.0.0",
6406 + "micromark-extension-gfm-tagfilter": "^2.0.0",
6407 + "micromark-extension-gfm-task-list-item": "^2.0.0",
6408 + "micromark-util-combine-extensions": "^2.0.0",
6409 + "micromark-util-types": "^2.0.0"
6410 + },
6411 + "funding": {
6412 + "type": "opencollective",
6413 + "url": "https://opencollective.com/unified"
6414 + }
6415 + },
6416 + "node_modules/micromark-extension-gfm-autolink-literal": {
6417 + "version": "2.1.0",
6418 + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
6419 + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
6420 + "license": "MIT",
6421 + "dependencies": {
6422 + "micromark-util-character": "^2.0.0",
6423 + "micromark-util-sanitize-uri": "^2.0.0",
6424 + "micromark-util-symbol": "^2.0.0",
6425 + "micromark-util-types": "^2.0.0"
6426 + },
6427 + "funding": {
6428 + "type": "opencollective",
6429 + "url": "https://opencollective.com/unified"
6430 + }
6431 + },
6432 + "node_modules/micromark-extension-gfm-footnote": {
6433 + "version": "2.1.0",
6434 + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
6435 + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
6436 + "license": "MIT",
6437 + "dependencies": {
6438 + "devlop": "^1.0.0",
6439 + "micromark-core-commonmark": "^2.0.0",
6440 + "micromark-factory-space": "^2.0.0",
6441 + "micromark-util-character": "^2.0.0",
6442 + "micromark-util-normalize-identifier": "^2.0.0",
6443 + "micromark-util-sanitize-uri": "^2.0.0",
6444 + "micromark-util-symbol": "^2.0.0",
6445 + "micromark-util-types": "^2.0.0"
6446 + },
6447 + "funding": {
6448 + "type": "opencollective",
6449 + "url": "https://opencollective.com/unified"
6450 + }
6451 + },
6452 + "node_modules/micromark-extension-gfm-strikethrough": {
6453 + "version": "2.1.0",
6454 + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
6455 + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
6456 + "license": "MIT",
6457 + "dependencies": {
6458 + "devlop": "^1.0.0",
6459 + "micromark-util-chunked": "^2.0.0",
6460 + "micromark-util-classify-character": "^2.0.0",
6461 + "micromark-util-resolve-all": "^2.0.0",
6462 + "micromark-util-symbol": "^2.0.0",
6463 + "micromark-util-types": "^2.0.0"
6464 + },
6465 + "funding": {
6466 + "type": "opencollective",
6467 + "url": "https://opencollective.com/unified"
6468 + }
6469 + },
6470 + "node_modules/micromark-extension-gfm-table": {
6471 + "version": "2.1.1",
6472 + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
6473 + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
6474 + "license": "MIT",
6475 + "dependencies": {
6476 + "devlop": "^1.0.0",
6477 + "micromark-factory-space": "^2.0.0",
6478 + "micromark-util-character": "^2.0.0",
6479 + "micromark-util-symbol": "^2.0.0",
6480 + "micromark-util-types": "^2.0.0"
6481 + },
6482 + "funding": {
6483 + "type": "opencollective",
6484 + "url": "https://opencollective.com/unified"
6485 + }
6486 + },
6487 + "node_modules/micromark-extension-gfm-tagfilter": {
6488 + "version": "2.0.0",
6489 + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
6490 + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
6491 + "license": "MIT",
6492 + "dependencies": {
6493 + "micromark-util-types": "^2.0.0"
6494 + },
6495 + "funding": {
6496 + "type": "opencollective",
6497 + "url": "https://opencollective.com/unified"
6498 + }
6499 + },
6500 + "node_modules/micromark-extension-gfm-task-list-item": {
6501 + "version": "2.1.0",
6502 + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
6503 + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
6504 + "license": "MIT",
6505 + "dependencies": {
6506 + "devlop": "^1.0.0",
6507 + "micromark-factory-space": "^2.0.0",
6508 + "micromark-util-character": "^2.0.0",
6509 + "micromark-util-symbol": "^2.0.0",
6510 + "micromark-util-types": "^2.0.0"
6511 + },
6512 + "funding": {
6513 + "type": "opencollective",
6514 + "url": "https://opencollective.com/unified"
6515 + }
6516 + },
6517 + "node_modules/micromark-factory-destination": {
6518 + "version": "2.0.1",
6519 + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
6520 + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
6521 + "funding": [
6522 + {
6523 + "type": "GitHub Sponsors",
6524 + "url": "https://github.com/sponsors/unifiedjs"
6525 + },
6526 + {
6527 + "type": "OpenCollective",
6528 + "url": "https://opencollective.com/unified"
6529 + }
6530 + ],
6531 + "license": "MIT",
6532 + "dependencies": {
6533 + "micromark-util-character": "^2.0.0",
6534 + "micromark-util-symbol": "^2.0.0",
6535 + "micromark-util-types": "^2.0.0"
6536 + }
6537 + },
6538 + "node_modules/micromark-factory-label": {
6539 + "version": "2.0.1",
6540 + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
6541 + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
6542 + "funding": [
6543 + {
6544 + "type": "GitHub Sponsors",
6545 + "url": "https://github.com/sponsors/unifiedjs"
6546 + },
6547 + {
6548 + "type": "OpenCollective",
6549 + "url": "https://opencollective.com/unified"
6550 + }
6551 + ],
6552 + "license": "MIT",
6553 + "dependencies": {
6554 + "devlop": "^1.0.0",
6555 + "micromark-util-character": "^2.0.0",
6556 + "micromark-util-symbol": "^2.0.0",
6557 + "micromark-util-types": "^2.0.0"
6558 + }
6559 + },
6560 + "node_modules/micromark-factory-space": {
6561 + "version": "2.0.1",
6562 + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
6563 + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
6564 + "funding": [
6565 + {
6566 + "type": "GitHub Sponsors",
6567 + "url": "https://github.com/sponsors/unifiedjs"
6568 + },
6569 + {
6570 + "type": "OpenCollective",
6571 + "url": "https://opencollective.com/unified"
6572 + }
6573 + ],
6574 + "license": "MIT",
6575 + "dependencies": {
6576 + "micromark-util-character": "^2.0.0",
6577 + "micromark-util-types": "^2.0.0"
6578 + }
6579 + },
6580 + "node_modules/micromark-factory-title": {
6581 + "version": "2.0.1",
6582 + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
6583 + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
6584 + "funding": [
6585 + {
6586 + "type": "GitHub Sponsors",
6587 + "url": "https://github.com/sponsors/unifiedjs"
6588 + },
6589 + {
6590 + "type": "OpenCollective",
6591 + "url": "https://opencollective.com/unified"
6592 + }
6593 + ],
6594 + "license": "MIT",
6595 + "dependencies": {
6596 + "micromark-factory-space": "^2.0.0",
6597 + "micromark-util-character": "^2.0.0",
6598 + "micromark-util-symbol": "^2.0.0",
6599 + "micromark-util-types": "^2.0.0"
6600 + }
6601 + },
6602 + "node_modules/micromark-factory-whitespace": {
6603 + "version": "2.0.1",
6604 + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
6605 + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
6606 + "funding": [
6607 + {
6608 + "type": "GitHub Sponsors",
6609 + "url": "https://github.com/sponsors/unifiedjs"
6610 + },
6611 + {
6612 + "type": "OpenCollective",
6613 + "url": "https://opencollective.com/unified"
6614 + }
6615 + ],
6616 + "license": "MIT",
6617 + "dependencies": {
6618 + "micromark-factory-space": "^2.0.0",
6619 + "micromark-util-character": "^2.0.0",
6620 + "micromark-util-symbol": "^2.0.0",
6621 + "micromark-util-types": "^2.0.0"
6622 + }
6623 + },
6624 + "node_modules/micromark-util-character": {
6625 + "version": "2.1.1",
6626 + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
6627 + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
6628 + "funding": [
6629 + {
6630 + "type": "GitHub Sponsors",
6631 + "url": "https://github.com/sponsors/unifiedjs"
6632 + },
6633 + {
6634 + "type": "OpenCollective",
6635 + "url": "https://opencollective.com/unified"
6636 + }
6637 + ],
6638 + "license": "MIT",
6639 + "dependencies": {
6640 + "micromark-util-symbol": "^2.0.0",
6641 + "micromark-util-types": "^2.0.0"
6642 + }
6643 + },
6644 + "node_modules/micromark-util-chunked": {
6645 + "version": "2.0.1",
6646 + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
6647 + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
6648 + "funding": [
6649 + {
6650 + "type": "GitHub Sponsors",
6651 + "url": "https://github.com/sponsors/unifiedjs"
6652 + },
6653 + {
6654 + "type": "OpenCollective",
6655 + "url": "https://opencollective.com/unified"
6656 + }
6657 + ],
6658 + "license": "MIT",
6659 + "dependencies": {
6660 + "micromark-util-symbol": "^2.0.0"
6661 + }
6662 + },
6663 + "node_modules/micromark-util-classify-character": {
6664 + "version": "2.0.1",
6665 + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
6666 + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
6667 + "funding": [
6668 + {
6669 + "type": "GitHub Sponsors",
6670 + "url": "https://github.com/sponsors/unifiedjs"
6671 + },
6672 + {
6673 + "type": "OpenCollective",
6674 + "url": "https://opencollective.com/unified"
6675 + }
6676 + ],
6677 + "license": "MIT",
6678 + "dependencies": {
6679 + "micromark-util-character": "^2.0.0",
6680 + "micromark-util-symbol": "^2.0.0",
6681 + "micromark-util-types": "^2.0.0"
6682 + }
6683 + },
6684 + "node_modules/micromark-util-combine-extensions": {
6685 + "version": "2.0.1",
6686 + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
6687 + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
6688 + "funding": [
6689 + {
6690 + "type": "GitHub Sponsors",
6691 + "url": "https://github.com/sponsors/unifiedjs"
6692 + },
6693 + {
6694 + "type": "OpenCollective",
6695 + "url": "https://opencollective.com/unified"
6696 + }
6697 + ],
6698 + "license": "MIT",
6699 + "dependencies": {
6700 + "micromark-util-chunked": "^2.0.0",
6701 + "micromark-util-types": "^2.0.0"
6702 + }
6703 + },
6704 + "node_modules/micromark-util-decode-numeric-character-reference": {
6705 + "version": "2.0.2",
6706 + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
6707 + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
6708 + "funding": [
6709 + {
6710 + "type": "GitHub Sponsors",
6711 + "url": "https://github.com/sponsors/unifiedjs"
6712 + },
6713 + {
6714 + "type": "OpenCollective",
6715 + "url": "https://opencollective.com/unified"
6716 + }
6717 + ],
6718 + "license": "MIT",
6719 + "dependencies": {
6720 + "micromark-util-symbol": "^2.0.0"
6721 + }
6722 + },
6723 + "node_modules/micromark-util-decode-string": {
6724 + "version": "2.0.1",
6725 + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
6726 + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
6727 + "funding": [
6728 + {
6729 + "type": "GitHub Sponsors",
6730 + "url": "https://github.com/sponsors/unifiedjs"
6731 + },
6732 + {
6733 + "type": "OpenCollective",
6734 + "url": "https://opencollective.com/unified"
6735 + }
6736 + ],
6737 + "license": "MIT",
6738 + "dependencies": {
6739 + "decode-named-character-reference": "^1.0.0",
6740 + "micromark-util-character": "^2.0.0",
6741 + "micromark-util-decode-numeric-character-reference": "^2.0.0",
6742 + "micromark-util-symbol": "^2.0.0"
6743 + }
6744 + },
6745 + "node_modules/micromark-util-encode": {
6746 + "version": "2.0.1",
6747 + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
6748 + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
6749 + "funding": [
6750 + {
6751 + "type": "GitHub Sponsors",
6752 + "url": "https://github.com/sponsors/unifiedjs"
6753 + },
6754 + {
6755 + "type": "OpenCollective",
6756 + "url": "https://opencollective.com/unified"
6757 + }
6758 + ],
6759 + "license": "MIT"
6760 + },
6761 + "node_modules/micromark-util-html-tag-name": {
6762 + "version": "2.0.1",
6763 + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
6764 + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
6765 + "funding": [
6766 + {
6767 + "type": "GitHub Sponsors",
6768 + "url": "https://github.com/sponsors/unifiedjs"
6769 + },
6770 + {
6771 + "type": "OpenCollective",
6772 + "url": "https://opencollective.com/unified"
6773 + }
6774 + ],
6775 + "license": "MIT"
6776 + },
6777 + "node_modules/micromark-util-normalize-identifier": {
6778 + "version": "2.0.1",
6779 + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
6780 + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
6781 + "funding": [
6782 + {
6783 + "type": "GitHub Sponsors",
6784 + "url": "https://github.com/sponsors/unifiedjs"
6785 + },
6786 + {
6787 + "type": "OpenCollective",
6788 + "url": "https://opencollective.com/unified"
6789 + }
6790 + ],
6791 + "license": "MIT",
6792 + "dependencies": {
6793 + "micromark-util-symbol": "^2.0.0"
6794 + }
6795 + },
6796 + "node_modules/micromark-util-resolve-all": {
6797 + "version": "2.0.1",
6798 + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
6799 + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
6800 + "funding": [
6801 + {
6802 + "type": "GitHub Sponsors",
6803 + "url": "https://github.com/sponsors/unifiedjs"
6804 + },
6805 + {
6806 + "type": "OpenCollective",
6807 + "url": "https://opencollective.com/unified"
6808 + }
6809 + ],
6810 + "license": "MIT",
6811 + "dependencies": {
6812 + "micromark-util-types": "^2.0.0"
6813 + }
6814 + },
6815 + "node_modules/micromark-util-sanitize-uri": {
6816 + "version": "2.0.1",
6817 + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
6818 + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
6819 + "funding": [
6820 + {
6821 + "type": "GitHub Sponsors",
6822 + "url": "https://github.com/sponsors/unifiedjs"
6823 + },
6824 + {
6825 + "type": "OpenCollective",
6826 + "url": "https://opencollective.com/unified"
6827 + }
6828 + ],
6829 + "license": "MIT",
6830 + "dependencies": {
6831 + "micromark-util-character": "^2.0.0",
6832 + "micromark-util-encode": "^2.0.0",
6833 + "micromark-util-symbol": "^2.0.0"
6834 + }
6835 + },
6836 + "node_modules/micromark-util-subtokenize": {
6837 + "version": "2.1.0",
6838 + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
6839 + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
6840 + "funding": [
6841 + {
6842 + "type": "GitHub Sponsors",
6843 + "url": "https://github.com/sponsors/unifiedjs"
6844 + },
6845 + {
6846 + "type": "OpenCollective",
6847 + "url": "https://opencollective.com/unified"
6848 + }
6849 + ],
6850 + "license": "MIT",
6851 + "dependencies": {
6852 + "devlop": "^1.0.0",
6853 + "micromark-util-chunked": "^2.0.0",
6854 + "micromark-util-symbol": "^2.0.0",
6855 + "micromark-util-types": "^2.0.0"
6856 + }
6857 + },
6858 + "node_modules/micromark-util-symbol": {
6859 + "version": "2.0.1",
6860 + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
6861 + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
6862 + "funding": [
6863 + {
6864 + "type": "GitHub Sponsors",
6865 + "url": "https://github.com/sponsors/unifiedjs"
6866 + },
6867 + {
6868 + "type": "OpenCollective",
6869 + "url": "https://opencollective.com/unified"
6870 + }
6871 + ],
6872 + "license": "MIT"
6873 + },
6874 + "node_modules/micromark-util-types": {
6875 + "version": "2.0.2",
6876 + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
6877 + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
6878 + "funding": [
6879 + {
6880 + "type": "GitHub Sponsors",
6881 + "url": "https://github.com/sponsors/unifiedjs"
6882 + },
6883 + {
6884 + "type": "OpenCollective",
6885 + "url": "https://opencollective.com/unified"
6886 + }
6887 + ],
6888 + "license": "MIT"
6889 + },
6890 + "node_modules/minimatch": {
6891 + "version": "10.2.6",
6892 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
6893 + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
6894 + "dev": true,
6895 + "license": "BlueOak-1.0.0",
6896 + "dependencies": {
6897 + "brace-expansion": "^5.0.8"
6898 + },
6899 + "engines": {
6900 + "node": "18 || 20 || >=22"
6901 + },
6902 + "funding": {
6903 + "url": "https://github.com/sponsors/isaacs"
6904 + }
6905 + },
6906 + "node_modules/minipass": {
6907 + "version": "7.1.3",
6908 + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
6909 + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
6910 + "dev": true,
6911 + "license": "BlueOak-1.0.0",
6912 + "engines": {
6913 + "node": ">=16 || 14 >=14.17"
6914 + }
6915 + },
6916 + "node_modules/ms": {
6917 + "version": "2.1.3",
6918 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
6919 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
6920 + "license": "MIT"
6921 + },
6922 + "node_modules/nanoid": {
6923 + "version": "3.3.16",
6924 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
6925 + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
6926 + "dev": true,
6927 + "funding": [
6928 + {
6929 + "type": "github",
6930 + "url": "https://github.com/sponsors/ai"
6931 + }
6932 + ],
6933 + "license": "MIT",
6934 + "bin": {
6935 + "nanoid": "bin/nanoid.cjs"
6936 + },
6937 + "engines": {
6938 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
6939 + }
6940 + },
6941 + "node_modules/natural-compare": {
6942 + "version": "1.4.0",
6943 + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
6944 + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
6945 + "dev": true,
5742 6946 "license": "MIT"
5743 6947 },
5744 6948 "node_modules/node-releases": {
@@ -5884,6 +7088,31 @@
5884 7088 "node": ">=6"
5885 7089 }
5886 7090 },
7091 + "node_modules/parse-entities": {
7092 + "version": "4.0.2",
7093 + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
7094 + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
7095 + "license": "MIT",
7096 + "dependencies": {
7097 + "@types/unist": "^2.0.0",
7098 + "character-entities-legacy": "^3.0.0",
7099 + "character-reference-invalid": "^2.0.0",
7100 + "decode-named-character-reference": "^1.0.0",
7101 + "is-alphanumerical": "^2.0.0",
7102 + "is-decimal": "^2.0.0",
7103 + "is-hexadecimal": "^2.0.0"
7104 + },
7105 + "funding": {
7106 + "type": "github",
7107 + "url": "https://github.com/sponsors/wooorm"
7108 + }
7109 + },
7110 + "node_modules/parse-entities/node_modules/@types/unist": {
7111 + "version": "2.0.11",
7112 + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
7113 + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
7114 + "license": "MIT"
7115 + },
5887 7116 "node_modules/path-exists": {
5888 7117 "version": "4.0.0",
5889 7118 "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -6083,6 +7312,16 @@
6083 7312 "url": "https://github.com/sponsors/sindresorhus"
6084 7313 }
6085 7314 },
7315 + "node_modules/property-information": {
7316 + "version": "7.2.0",
7317 + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
7318 + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
7319 + "license": "MIT",
7320 + "funding": {
7321 + "type": "github",
7322 + "url": "https://github.com/sponsors/wooorm"
7323 + }
7324 + },
6086 7325 "node_modules/punycode": {
6087 7326 "version": "2.3.1",
6088 7327 "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6114,6 +7353,33 @@
6114 7353 "react": "^19.2.8"
6115 7354 }
6116 7355 },
7356 + "node_modules/react-markdown": {
7357 + "version": "10.1.0",
7358 + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
7359 + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
7360 + "license": "MIT",
7361 + "dependencies": {
7362 + "@types/hast": "^3.0.0",
7363 + "@types/mdast": "^4.0.0",
7364 + "devlop": "^1.0.0",
7365 + "hast-util-to-jsx-runtime": "^2.0.0",
7366 + "html-url-attributes": "^3.0.0",
7367 + "mdast-util-to-hast": "^13.0.0",
7368 + "remark-parse": "^11.0.0",
7369 + "remark-rehype": "^11.0.0",
7370 + "unified": "^11.0.0",
7371 + "unist-util-visit": "^5.0.0",
7372 + "vfile": "^6.0.0"
7373 + },
7374 + "funding": {
7375 + "type": "opencollective",
7376 + "url": "https://opencollective.com/unified"
7377 + },
7378 + "peerDependencies": {
7379 + "@types/react": ">=18",
7380 + "react": ">=18"
7381 + }
7382 + },
6117 7383 "node_modules/react-refresh": {
6118 7384 "version": "0.17.0",
6119 7385 "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -6226,6 +7492,89 @@
6226 7492 "regjsparser": "bin/parser"
6227 7493 }
6228 7494 },
7495 + "node_modules/rehype-highlight": {
7496 + "version": "7.0.2",
7497 + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz",
7498 + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==",
7499 + "license": "MIT",
7500 + "dependencies": {
7501 + "@types/hast": "^3.0.0",
7502 + "hast-util-to-text": "^4.0.0",
7503 + "lowlight": "^3.0.0",
7504 + "unist-util-visit": "^5.0.0",
7505 + "vfile": "^6.0.0"
7506 + },
7507 + "funding": {
7508 + "type": "opencollective",
7509 + "url": "https://opencollective.com/unified"
7510 + }
7511 + },
7512 + "node_modules/remark-gfm": {
7513 + "version": "4.0.1",
7514 + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
7515 + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
7516 + "license": "MIT",
7517 + "dependencies": {
7518 + "@types/mdast": "^4.0.0",
7519 + "mdast-util-gfm": "^3.0.0",
7520 + "micromark-extension-gfm": "^3.0.0",
7521 + "remark-parse": "^11.0.0",
7522 + "remark-stringify": "^11.0.0",
7523 + "unified": "^11.0.0"
7524 + },
7525 + "funding": {
7526 + "type": "opencollective",
7527 + "url": "https://opencollective.com/unified"
7528 + }
7529 + },
7530 + "node_modules/remark-parse": {
7531 + "version": "11.0.0",
7532 + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
7533 + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
7534 + "license": "MIT",
7535 + "dependencies": {
7536 + "@types/mdast": "^4.0.0",
7537 + "mdast-util-from-markdown": "^2.0.0",
7538 + "micromark-util-types": "^2.0.0",
7539 + "unified": "^11.0.0"
7540 + },
7541 + "funding": {
7542 + "type": "opencollective",
7543 + "url": "https://opencollective.com/unified"
7544 + }
7545 + },
7546 + "node_modules/remark-rehype": {
7547 + "version": "11.1.2",
7548 + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
7549 + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
7550 + "license": "MIT",
7551 + "dependencies": {
7552 + "@types/hast": "^3.0.0",
7553 + "@types/mdast": "^4.0.0",
7554 + "mdast-util-to-hast": "^13.0.0",
7555 + "unified": "^11.0.0",
7556 + "vfile": "^6.0.0"
7557 + },
7558 + "funding": {
7559 + "type": "opencollective",
7560 + "url": "https://opencollective.com/unified"
7561 + }
7562 + },
7563 + "node_modules/remark-stringify": {
7564 + "version": "11.0.0",
7565 + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
7566 + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
7567 + "license": "MIT",
7568 + "dependencies": {
7569 + "@types/mdast": "^4.0.0",
7570 + "mdast-util-to-markdown": "^2.0.0",
7571 + "unified": "^11.0.0"
7572 + },
7573 + "funding": {
7574 + "type": "opencollective",
7575 + "url": "https://opencollective.com/unified"
7576 + }
7577 + },
6229 7578 "node_modules/require-from-string": {
6230 7579 "version": "2.0.2",
6231 7580 "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -6609,6 +7958,16 @@
6609 7958 "node": ">=0.10.0"
6610 7959 }
6611 7960 },
7961 + "node_modules/space-separated-tokens": {
7962 + "version": "2.0.2",
7963 + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
7964 + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
7965 + "license": "MIT",
7966 + "funding": {
7967 + "type": "github",
7968 + "url": "https://github.com/sponsors/wooorm"
7969 + }
7970 + },
6612 7971 "node_modules/stop-iteration-iterator": {
6613 7972 "version": "1.1.0",
6614 7973 "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -6711,6 +8070,20 @@
6711 8070 "url": "https://github.com/sponsors/ljharb"
6712 8071 }
6713 8072 },
8073 + "node_modules/stringify-entities": {
8074 + "version": "4.0.4",
8075 + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
8076 + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
8077 + "license": "MIT",
8078 + "dependencies": {
8079 + "character-entities-html4": "^2.0.0",
8080 + "character-entities-legacy": "^3.0.0"
8081 + },
8082 + "funding": {
8083 + "type": "github",
8084 + "url": "https://github.com/sponsors/wooorm"
8085 + }
8086 + },
6714 8087 "node_modules/stringify-object": {
6715 8088 "version": "3.3.0",
6716 8089 "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz",
@@ -6749,6 +8122,24 @@
6749 8122 "url": "https://github.com/sponsors/sindresorhus"
6750 8123 }
6751 8124 },
8125 + "node_modules/style-to-js": {
8126 + "version": "1.1.21",
8127 + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
8128 + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
8129 + "license": "MIT",
8130 + "dependencies": {
8131 + "style-to-object": "1.0.14"
8132 + }
8133 + },
8134 + "node_modules/style-to-object": {
8135 + "version": "1.0.14",
8136 + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
8137 + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
8138 + "license": "MIT",
8139 + "dependencies": {
8140 + "inline-style-parser": "0.2.7"
8141 + }
8142 + },
6752 8143 "node_modules/supports-color": {
6753 8144 "version": "7.2.0",
6754 8145 "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -6840,6 +8231,26 @@
6840 8231 "url": "https://github.com/sponsors/SuperchupuDev"
6841 8232 }
6842 8233 },
8234 + "node_modules/trim-lines": {
8235 + "version": "3.0.1",
8236 + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
8237 + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
8238 + "license": "MIT",
8239 + "funding": {
8240 + "type": "github",
8241 + "url": "https://github.com/sponsors/wooorm"
8242 + }
8243 + },
8244 + "node_modules/trough": {
8245 + "version": "2.2.0",
8246 + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
8247 + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
8248 + "license": "MIT",
8249 + "funding": {
8250 + "type": "github",
8251 + "url": "https://github.com/sponsors/wooorm"
8252 + }
8253 + },
6843 8254 "node_modules/ts-api-utils": {
6844 8255 "version": "2.5.0",
6845 8256 "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -7034,6 +8445,25 @@
7034 8445 "node": ">=4"
7035 8446 }
7036 8447 },
8448 + "node_modules/unified": {
8449 + "version": "11.0.5",
8450 + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
8451 + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
8452 + "license": "MIT",
8453 + "dependencies": {
8454 + "@types/unist": "^3.0.0",
8455 + "bail": "^2.0.0",
8456 + "devlop": "^1.0.0",
8457 + "extend": "^3.0.0",
8458 + "is-plain-obj": "^4.0.0",
8459 + "trough": "^2.0.0",
8460 + "vfile": "^6.0.0"
8461 + },
8462 + "funding": {
8463 + "type": "opencollective",
8464 + "url": "https://opencollective.com/unified"
8465 + }
8466 + },
7037 8467 "node_modules/unique-string": {
7038 8468 "version": "2.0.0",
7039 8469 "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
@@ -7047,6 +8477,88 @@
7047 8477 "node": ">=8"
7048 8478 }
7049 8479 },
8480 + "node_modules/unist-util-find-after": {
8481 + "version": "5.0.0",
8482 + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
8483 + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
8484 + "license": "MIT",
8485 + "dependencies": {
8486 + "@types/unist": "^3.0.0",
8487 + "unist-util-is": "^6.0.0"
8488 + },
8489 + "funding": {
8490 + "type": "opencollective",
8491 + "url": "https://opencollective.com/unified"
8492 + }
8493 + },
8494 + "node_modules/unist-util-is": {
8495 + "version": "6.0.1",
8496 + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
8497 + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
8498 + "license": "MIT",
8499 + "dependencies": {
8500 + "@types/unist": "^3.0.0"
8501 + },
8502 + "funding": {
8503 + "type": "opencollective",
8504 + "url": "https://opencollective.com/unified"
8505 + }
8506 + },
8507 + "node_modules/unist-util-position": {
8508 + "version": "5.0.0",
8509 + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
8510 + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
8511 + "license": "MIT",
8512 + "dependencies": {
8513 + "@types/unist": "^3.0.0"
8514 + },
8515 + "funding": {
8516 + "type": "opencollective",
8517 + "url": "https://opencollective.com/unified"
8518 + }
8519 + },
8520 + "node_modules/unist-util-stringify-position": {
8521 + "version": "4.0.0",
8522 + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
8523 + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
8524 + "license": "MIT",
8525 + "dependencies": {
8526 + "@types/unist": "^3.0.0"
8527 + },
8528 + "funding": {
8529 + "type": "opencollective",
8530 + "url": "https://opencollective.com/unified"
8531 + }
8532 + },
8533 + "node_modules/unist-util-visit": {
8534 + "version": "5.1.0",
8535 + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
8536 + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
8537 + "license": "MIT",
8538 + "dependencies": {
8539 + "@types/unist": "^3.0.0",
8540 + "unist-util-is": "^6.0.0",
8541 + "unist-util-visit-parents": "^6.0.0"
8542 + },
8543 + "funding": {
8544 + "type": "opencollective",
8545 + "url": "https://opencollective.com/unified"
8546 + }
8547 + },
8548 + "node_modules/unist-util-visit-parents": {
8549 + "version": "6.0.2",
8550 + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
8551 + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
8552 + "license": "MIT",
8553 + "dependencies": {
8554 + "@types/unist": "^3.0.0",
8555 + "unist-util-is": "^6.0.0"
8556 + },
8557 + "funding": {
8558 + "type": "opencollective",
8559 + "url": "https://opencollective.com/unified"
8560 + }
8561 + },
7050 8562 "node_modules/universalify": {
7051 8563 "version": "2.0.1",
7052 8564 "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -7109,6 +8621,34 @@
7109 8621 "punycode": "^2.1.0"
7110 8622 }
7111 8623 },
8624 + "node_modules/vfile": {
8625 + "version": "6.0.3",
8626 + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
8627 + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
8628 + "license": "MIT",
8629 + "dependencies": {
8630 + "@types/unist": "^3.0.0",
8631 + "vfile-message": "^4.0.0"
8632 + },
8633 + "funding": {
8634 + "type": "opencollective",
8635 + "url": "https://opencollective.com/unified"
8636 + }
8637 + },
8638 + "node_modules/vfile-message": {
8639 + "version": "4.0.3",
8640 + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
8641 + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
8642 + "license": "MIT",
8643 + "dependencies": {
8644 + "@types/unist": "^3.0.0",
8645 + "unist-util-stringify-position": "^4.0.0"
8646 + },
8647 + "funding": {
8648 + "type": "opencollective",
8649 + "url": "https://opencollective.com/unified"
8650 + }
8651 + },
7112 8652 "node_modules/vite": {
7113 8653 "version": "7.3.6",
7114 8654 "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
@@ -7639,6 +9179,16 @@
7639 9179 "optional": true
7640 9180 }
7641 9181 }
9182 + },
9183 + "node_modules/zwitch": {
9184 + "version": "2.0.4",
9185 + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
9186 + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
9187 + "license": "MIT",
9188 + "funding": {
9189 + "type": "github",
9190 + "url": "https://github.com/sponsors/wooorm"
9191 + }
7642 9192 }
7643 9193 }
7644 9194 }
modified package.json +4 −0
@@ -14,8 +14,12 @@
14 14 "check": "npm run typecheck && npm run lint && npm run build"
15 15 },
16 16 "dependencies": {
17 + "highlight.js": "^11.11.1",
17 18 "react": "^19.1.0",
18 19 "react-dom": "^19.1.0",
20 + "react-markdown": "^10.1.0",
21 + "rehype-highlight": "^7.0.2",
22 + "remark-gfm": "^4.0.1",
19 23 "zustand": "^5.0.6"
20 24 },
21 25 "devDependencies": {
modified src/App.tsx +122 −18
@@ -4,31 +4,135 @@
4 4 *
5 5 * Author: Simon-Pierre Boucher
6 6 * Mail: contact@spboucher.ai
7 + *
8 + * The app shell: sidebar + chat area (drawer on phones), root empty state,
9 + * first-run notice, settings modal, toasts, and global keyboard shortcuts
10 + * (⌘N new chat, ⌘K model menu/palette, ⌘F search, ⌘, settings, ⌘. stop,
11 + * ⌘⇧E export).
7 12 */
8 13
14 +import { useEffect } from 'react'
15 +import ChatView from './components/ChatView'
16 +import FirstRun from './components/FirstRun'
17 +import ModelMenu from './components/ModelMenu'
18 +import SettingsModal from './components/SettingsModal'
19 +import Sidebar from './components/Sidebar'
20 +import SuggestionGrid from './components/SuggestionGrid'
21 +import Toasts from './components/Toasts'
9 22 import StreamTestView from './features/StreamTestView'
23 +import { exportConversationMarkdown } from './features/exporters'
24 +import { useStore } from './state/store'
25 +import './design/components.css'
10 26
11 // Phase 1 placeholder shell — replaced by the real chat layout in Phase 4.
12 // #stream-test mounts the bare streaming view (Phase 2 gate; kept for Phase 7).
13 27 export default function App() {
28 + const loaded = useStore((s) => s.loaded)
29 + const loadAll = useStore((s) => s.loadAll)
30 + const conversations = useStore((s) => s.conversations)
31 + const selectedID = useStore((s) => s.selectedID)
32 + const settings = useStore((s) => s.settings)
33 + const settingsOpen = useStore((s) => s.settingsOpen)
34 + const sidebarOpen = useStore((s) => s.sidebarOpen)
35 + const setSidebarOpen = useStore((s) => s.setSidebarOpen)
36 + const modelMenuOpen = useStore((s) => s.modelMenuOpen)
37 + const setModelMenuOpen = useStore((s) => s.setModelMenuOpen)
38 + const newConversation = useStore((s) => s.newConversation)
39 + const openSettings = useStore((s) => s.openSettings)
40 + const stop = useStore((s) => s.stop)
41 + const send = useStore((s) => s.send)
42 + const updateSettings = useStore((s) => s.updateSettings)
43 +
44 + const selected = conversations.find((c) => c.id === selectedID) ?? null
45 +
46 + useEffect(() => {
47 + void loadAll()
48 + }, [loadAll])
49 +
50 + // Global shortcuts
51 + useEffect(() => {
52 + const onKeyDown = (e: KeyboardEvent) => {
53 + const mod = e.metaKey || e.ctrlKey
54 + if (!mod) return
55 + const state = useStore.getState()
56 + switch (e.key) {
57 + case 'n':
58 + e.preventDefault()
59 + state.newConversation()
60 + break
61 + case 'k':
62 + e.preventDefault()
63 + state.setModelMenuOpen(!state.modelMenuOpen)
64 + break
65 + case 'f': {
66 + e.preventDefault()
67 + const input = document.querySelector<HTMLInputElement>('[data-search-input]')
68 + input?.focus()
69 + break
70 + }
71 + case ',':
72 + e.preventDefault()
73 + state.openSettings()
74 + break
75 + case '.':
76 + e.preventDefault()
77 + if (state.selectedID) state.stop(state.selectedID)
78 + break
79 + case 'E':
80 + if (e.shiftKey) {
81 + e.preventDefault()
82 + const conversation = state.conversations.find((c) => c.id === state.selectedID)
83 + if (conversation) exportConversationMarkdown(conversation)
84 + }
85 + break
86 + }
87 + }
88 + window.addEventListener('keydown', onKeyDown)
89 + return () => window.removeEventListener('keydown', onKeyDown)
90 + }, [newConversation, openSettings, stop])
91 +
14 92 if (window.location.hash === '#stream-test') return <StreamTestView />
93 +
94 + if (!loaded) {
95 + return (
96 + <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
97 + <span style={{ color: 'var(--z-text-tertiary)' }}>Loading…</span>
98 + </div>
99 + )
100 + }
101 +
15 102 return (
16 <div
17 style={{
18 display: 'flex',
19 alignItems: 'center',
20 justifyContent: 'center',
21 height: '100%',
22 flexDirection: 'column',
23 gap: 'var(--z-space-sm)',
24 }}
25 >
26 <h1 style={{ font: '600 var(--z-font-size-title) var(--z-font-sans)', margin: 0 }}>
27 Zyquo Cloud
28 </h1>
29 <p style={{ color: 'var(--z-text-secondary)', margin: 0 }}>
30 Your keys, every cloud model, one beautiful chat.
31 </p>
103 + <div className={`shell${settings.focusMode ? ' focus-mode' : ''}`}>
104 + <Sidebar />
105 + {sidebarOpen && <div className="drawer-scrim" onClick={() => setSidebarOpen(false)} />}
106 + {selected ? (
107 + <ChatView conversation={selected} />
108 + ) : (
109 + <div className="chat-area">
110 + <SuggestionGrid
111 + onPick={(prompt) => {
112 + const conversation = newConversation()
113 + // Native behavior: self-contained suggestions send immediately;
114 + // ones expecting pasted input (trailing blank line) seed the draft.
115 + if (prompt.endsWith('\n\n')) {
116 + useStore.getState().setDraftSeed(prompt)
117 + } else {
118 + void send(conversation.id, prompt, [])
119 + }
120 + }}
121 + />
122 + {modelMenuOpen && (
123 + <ModelMenu
124 + onPick={(model) => {
125 + updateSettings({ defaultModelID: model.id, defaultProvider: model.provider })
126 + newConversation({ model })
127 + }}
128 + onClose={() => setModelMenuOpen(false)}
129 + />
130 + )}
131 + </div>
132 + )}
133 + {settingsOpen && <SettingsModal />}
134 + {!settings.firstRunAcknowledged && <FirstRun />}
135 + <Toasts />
32 136 </div>
33 137 )
34 138 }
added src/components/ChatView.tsx +277 −0
@@ -0,0 +1,277 @@
1 +/*
2 + * ChatView.tsx
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * The chat area: header (editable title, centered model chip, actions),
9 + * transcript with scroll-lock + "jump to latest" pill, input bar, and the
10 + * parameters popover. Suggestions grid when the conversation is empty.
11 + */
12 +
13 +import { useEffect, useMemo, useRef, useState } from 'react'
14 +import { findModel } from '../features/catalogHelpers'
15 +import { useStore } from '../state/store'
16 +import type { AIModel, Conversation } from '../types'
17 +import InputBar from './InputBar'
18 +import { MessageBubble } from './MessageBubble'
19 +import ModelMenu from './ModelMenu'
20 +import ParamsPanel from './ParamsPanel'
21 +import SuggestionGrid from './SuggestionGrid'
22 +import {
23 + IconArrowDown,
24 + IconChevronDown,
25 + IconColumns,
26 + IconDownload,
27 + IconInfo,
28 + IconMenu,
29 + ProviderGlyph,
30 +} from './icons'
31 +import { exportConversationMarkdown } from '../features/exporters'
32 +
33 +export default function ChatView({ conversation }: { conversation: Conversation }) {
34 + const updateConversation = useStore((s) => s.updateConversation)
35 + const streaming = useStore((s) => s.streaming[conversation.id])
36 + const setSidebarOpen = useStore((s) => s.setSidebarOpen)
37 + const setCompareOpen = useStore((s) => s.setCompareOpen)
38 + const updateSettings = useStore((s) => s.updateSettings)
39 + const modelMenuOpen = useStore((s) => s.modelMenuOpen)
40 + const setModelMenuOpen = useStore((s) => s.setModelMenuOpen)
41 + const density = useStore((s) => s.settings.density)
42 +
43 + const [editingTitle, setEditingTitle] = useState(false)
44 + const [titleDraft, setTitleDraft] = useState(conversation.title)
45 + const [paramsOpen, setParamsOpen] = useState(false)
46 + const [infoOpen, setInfoOpen] = useState(false)
47 + const draftSeed = useStore((s) => s.draftSeed)
48 + const setDraftSeed = useStore((s) => s.setDraftSeed)
49 + const [draft, setDraft] = useState('')
50 + const [pinnedToBottom, setPinnedToBottom] = useState(true)
51 +
52 + // Pick up a prompt seeded from the root empty state's suggestion cards.
53 + useEffect(() => {
54 + if (draftSeed !== null) {
55 + setDraft(draftSeed)
56 + setDraftSeed(null)
57 + document.querySelector<HTMLTextAreaElement>('[data-input-editor]')?.focus()
58 + }
59 + }, [draftSeed, setDraftSeed])
60 + const scrollRef = useRef<HTMLDivElement>(null)
61 +
62 + const model = findModel(conversation.provider, conversation.modelID)
63 +
64 + // Auto-scroll while pinned to bottom.
65 + const lastMessage = conversation.messages[conversation.messages.length - 1]
66 + const scrollFingerprint = `${conversation.messages.length}:${lastMessage?.text.length ?? 0}:${
67 + lastMessage?.reasoning?.length ?? 0
68 + }`
69 + useEffect(() => {
70 + if (pinnedToBottom && scrollRef.current) {
71 + scrollRef.current.scrollTop = scrollRef.current.scrollHeight
72 + }
73 + }, [scrollFingerprint, pinnedToBottom])
74 +
75 + const onScroll = () => {
76 + const el = scrollRef.current
77 + if (!el) return
78 + setPinnedToBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 60)
79 + }
80 +
81 + const totals = useMemo(() => {
82 + let input = 0
83 + let output = 0
84 + let cost = 0
85 + for (const message of conversation.messages) {
86 + input += message.usage?.inputTokens ?? 0
87 + output += message.usage?.outputTokens ?? 0
88 + cost += message.estimatedCost ?? 0
89 + }
90 + return { input, output, cost }
91 + }, [conversation])
92 +
93 + const pickModel = (picked: AIModel, scope: 'conversation' | 'default' | 'message') => {
94 + if (scope === 'default') {
95 + updateSettings({ defaultModelID: picked.id, defaultProvider: picked.provider })
96 + }
97 + updateConversation(conversation.id, { modelID: picked.id, provider: picked.provider })
98 + }
99 +
100 + const quote = (text: string) => {
101 + const quoted = text
102 + .split('\n')
103 + .map((line) => `> ${line}`)
104 + .join('\n')
105 + setDraft((d) => `${quoted}\n\n${d}`)
106 + }
107 +
108 + return (
109 + <div className={`chat-area density-${density}`}>
110 + <header className="chat-header">
111 + <div className="chat-header-left">
112 + <button
113 + className="icon-button menu-button"
114 + title="Conversations"
115 + onClick={() => setSidebarOpen(true)}
116 + >
117 + <IconMenu />
118 + </button>
119 + {editingTitle ? (
120 + <input
121 + className="chat-title-input"
122 + value={titleDraft}
123 + autoFocus
124 + onChange={(e) => setTitleDraft(e.target.value)}
125 + onBlur={() => {
126 + setEditingTitle(false)
127 + if (titleDraft.trim() !== '') {
128 + updateConversation(conversation.id, {
129 + title: titleDraft.trim(),
130 + hasAutoTitle: false,
131 + })
132 + }
133 + }}
134 + onKeyDown={(e) => {
135 + if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
136 + if (e.key === 'Escape') setEditingTitle(false)
137 + }}
138 + />
139 + ) : (
140 + <span
141 + className="chat-title"
142 + title="Double-click to rename"
143 + onDoubleClick={() => {
144 + setTitleDraft(conversation.title)
145 + setEditingTitle(true)
146 + }}
147 + >
148 + {conversation.title}
149 + </span>
150 + )}
151 + </div>
152 + <button className="model-chip" onClick={() => setModelMenuOpen(true)} data-model-chip>
153 + <span className="glyph">
154 + <ProviderGlyph provider={conversation.provider} />
155 + </span>
156 + {model?.displayName ?? conversation.modelID}
157 + <span className="chevron">
158 + <IconChevronDown />
159 + </span>
160 + </button>
161 + <div className="chat-header-right">
162 + <button className="icon-button" title="Compare models" onClick={() => setCompareOpen(true)}>
163 + <IconColumns />
164 + </button>
165 + <button
166 + className="icon-button"
167 + title="Export conversation (⌘⇧E)"
168 + onClick={() => exportConversationMarkdown(conversation)}
169 + >
170 + <IconDownload />
171 + </button>
172 + <button className="icon-button" title="Conversation info" onClick={() => setInfoOpen(true)}>
173 + <IconInfo />
174 + </button>
175 + </div>
176 + </header>
177 +
178 + {conversation.messages.length === 0 ? (
179 + <SuggestionGrid
180 + onPick={(prompt) => setDraft(prompt)}
181 + modelChip={
182 + <button className="model-chip" onClick={() => setModelMenuOpen(true)}>
183 + <span className="glyph">
184 + <ProviderGlyph provider={conversation.provider} />
185 + </span>
186 + {model?.displayName ?? conversation.modelID}
187 + <span className="chevron">
188 + <IconChevronDown />
189 + </span>
190 + </button>
191 + }
192 + />
193 + ) : (
194 + <div className="transcript" ref={scrollRef} onScroll={onScroll}>
195 + <div className="transcript-column">
196 + {conversation.messages.map((message) => (
197 + <MessageBubble
198 + key={message.id}
199 + conversationID={conversation.id}
200 + message={message}
201 + streaming={streaming?.messageID === message.id}
202 + onQuote={quote}
203 + />
204 + ))}
205 + </div>
206 + {!pinnedToBottom && streaming && (
207 + <button
208 + className="jump-pill"
209 + onClick={() => {
210 + setPinnedToBottom(true)
211 + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
212 + }}
213 + >
214 + <IconArrowDown /> Jump to latest
215 + </button>
216 + )}
217 + </div>
218 + )}
219 +
220 + <InputBar
221 + conversation={conversation}
222 + draft={draft}
223 + setDraft={setDraft}
224 + onOpenParams={() => setParamsOpen(true)}
225 + />
226 +
227 + {modelMenuOpen && (
228 + <ModelMenu
229 + currentID={conversation.modelID}
230 + onPick={pickModel}
231 + onClose={() => setModelMenuOpen(false)}
232 + />
233 + )}
234 + {paramsOpen && (
235 + <ParamsPanel conversation={conversation} onClose={() => setParamsOpen(false)} />
236 + )}
237 + {infoOpen && (
238 + <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && setInfoOpen(false)}>
239 + <div className="panel" style={{ width: 400 }}>
240 + <div className="settings-content">
241 + <h3>Conversation</h3>
242 + <div className="settings-row">
243 + <span>Tokens</span>
244 + <span style={{ marginLeft: 'auto', color: 'var(--z-text-secondary)' }}>
245 + {totals.input.toLocaleString()} in · {totals.output.toLocaleString()} out
246 + </span>
247 + </div>
248 + <div className="settings-row">
249 + <span>Estimated cost</span>
250 + <span style={{ marginLeft: 'auto', color: 'var(--z-text-secondary)' }}>
251 + ~${totals.cost.toFixed(4)}
252 + </span>
253 + </div>
254 + <div className="settings-section" style={{ marginTop: 12 }}>
255 + <h3>System prompt</h3>
256 + <textarea
257 + className="text-input"
258 + rows={5}
259 + placeholder="No system prompt"
260 + value={conversation.systemPrompt ?? ''}
261 + onChange={(e) =>
262 + updateConversation(conversation.id, { systemPrompt: e.target.value })
263 + }
264 + />
265 + </div>
266 + <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
267 + <button className="button" onClick={() => setInfoOpen(false)}>
268 + Done
269 + </button>
270 + </div>
271 + </div>
272 + </div>
273 + </div>
274 + )}
275 + </div>
276 + )
277 +}
added src/components/FirstRun.tsx +63 −0
@@ -0,0 +1,63 @@
1 +/*
2 + * FirstRun.tsx
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * First-run welcome: clean, honest, transparent about local storage.
9 + */
10 +
11 +import { useStore } from '../state/store'
12 +import ZyquoGlyph from './ZyquoGlyph'
13 +
14 +export default function FirstRun() {
15 + const updateSettings = useStore((s) => s.updateSettings)
16 + const openSettings = useStore((s) => s.openSettings)
17 +
18 + return (
19 + <div className="overlay">
20 + <div className="panel" style={{ width: 520 }}>
21 + <div className="settings-content" style={{ padding: 24 }}>
22 + <div style={{ display: 'flex', justifyContent: 'center', marginBottom: 16 }}>
23 + <ZyquoGlyph size={56} />
24 + </div>
25 + <h3 style={{ textAlign: 'center', fontSize: 18 }}>Welcome to Zyquo Cloud</h3>
26 + <p style={{ color: 'var(--z-text-secondary)', textAlign: 'center', marginTop: 4 }}>
27 + Every cloud model, one beautiful chat — running entirely in your browser.
28 + </p>
29 + <div className="first-run-note" style={{ marginTop: 16 }}>
30 + <span>
31 + <strong>No account. No backend. Nothing leaves this device.</strong>
32 + </span>
33 + <span>
34 + Your API keys and conversation history are stored <strong>in this browser</strong>
35 + {' '}(localStorage / IndexedDB) and are sent only to the AI provider you choose,
36 + directly. Keys in browser storage aren't a secure vault — anyone with access to this
37 + browser profile could read them. An optional passphrase lock is available in
38 + Settings → Advanced.
39 + </span>
40 + <span>Add a provider API key to begin.</span>
41 + </div>
42 + <div className="first-run-actions" style={{ marginTop: 16 }}>
43 + <button
44 + className="button"
45 + onClick={() => updateSettings({ firstRunAcknowledged: true })}
46 + >
47 + Explore first
48 + </button>
49 + <button
50 + className="button primary"
51 + onClick={() => {
52 + updateSettings({ firstRunAcknowledged: true })
53 + openSettings('providers')
54 + }}
55 + >
56 + Add a key
57 + </button>
58 + </div>
59 + </div>
60 + </div>
61 + </div>
62 + )
63 +}
added src/components/InputBar.tsx +249 −0
@@ -0,0 +1,249 @@
1 +/*
2 + * InputBar.tsx
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Floating input card: multiline auto-grow editor, image attach/paste for
9 + * vision models, text-file attach, drag-and-drop, params popover trigger,
10 + * circular accent send (⌘/Ctrl+Enter) that becomes Stop while streaming,
11 + * token/cost HUD with context-window usage bar.
12 + */
13 +
14 +import { useEffect, useMemo, useRef, useState } from 'react'
15 +import { estimateTokens, findModel } from '../features/catalogHelpers'
16 +import { useStore } from '../state/store'
17 +import type { Attachment, Conversation } from '../types'
18 +import { uid } from '../state/store'
19 +import { IconPaperclip, IconSend, IconSliders, IconStop, IconX } from './icons'
20 +
21 +const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
22 +const MAX_TEXT_FILE = 512 * 1024
23 +
24 +export default function InputBar({
25 + conversation,
26 + draft,
27 + setDraft,
28 + onOpenParams,
29 +}: {
30 + conversation: Conversation
31 + draft: string
32 + setDraft: (text: string) => void
33 + onOpenParams: () => void
34 +}) {
35 + const send = useStore((s) => s.send)
36 + const stop = useStore((s) => s.stop)
37 + const streaming = useStore((s) => s.streaming[conversation.id])
38 + const toast = useStore((s) => s.toast)
39 + const [attachments, setAttachments] = useState<Attachment[]>([])
40 + const [dragging, setDragging] = useState(false)
41 + const editorRef = useRef<HTMLTextAreaElement>(null)
42 + const fileRef = useRef<HTMLInputElement>(null)
43 +
44 + const model = findModel(conversation.provider, conversation.modelID)
45 + const vision = model?.capabilities.vision ?? false
46 +
47 + useEffect(() => {
48 + const editor = editorRef.current
49 + if (!editor) return
50 + editor.style.height = 'auto'
51 + editor.style.height = `${Math.min(editor.scrollHeight, 200)}px`
52 + }, [draft])
53 +
54 + const contextUsage = useMemo(() => {
55 + if (!model) return 0
56 + let chars = (conversation.systemPrompt ?? '').length + draft.length
57 + for (const message of conversation.messages) chars += message.text.length
58 + return Math.min(1, estimateTokens('x'.repeat(chars)) / model.contextWindow)
59 + }, [conversation, draft, model])
60 +
61 + const addFiles = async (files: FileList | File[]) => {
62 + for (const file of files) {
63 + if (IMAGE_TYPES.includes(file.type)) {
64 + if (!vision) {
65 + toast(`${model?.displayName ?? 'This model'} doesn't support images`, 'error')
66 + continue
67 + }
68 + const data = await fileToBase64(file)
69 + setAttachments((prev) => [
70 + ...prev,
71 + { id: uid(), kind: 'image', fileName: file.name, mimeType: file.type, data },
72 + ])
73 + } else if (file.size <= MAX_TEXT_FILE) {
74 + const text = await file.text()
75 + setAttachments((prev) => [
76 + ...prev,
77 + {
78 + id: uid(),
79 + kind: 'textFile',
80 + fileName: file.name,
81 + mimeType: file.type || 'text/plain',
82 + data: text,
83 + },
84 + ])
85 + } else {
86 + toast(`${file.name} is too large (max 512 KB for text files)`, 'error')
87 + }
88 + }
89 + }
90 +
91 + const doSend = () => {
92 + const text = draft.trim()
93 + if (text === '' && attachments.length === 0) return
94 + setDraft('')
95 + const toSend = attachments
96 + setAttachments([])
97 + void send(conversation.id, text, toSend)
98 + }
99 +
100 + const onKeyDown = (e: React.KeyboardEvent) => {
101 + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
102 + e.preventDefault()
103 + doSend()
104 + }
105 + }
106 +
107 + const onPaste = (e: React.ClipboardEvent) => {
108 + const files = [...e.clipboardData.files]
109 + if (files.length > 0) {
110 + e.preventDefault()
111 + void addFiles(files)
112 + }
113 + }
114 +
115 + const totalTokens = useMemo(() => {
116 + let tokens = 0
117 + let cost = 0
118 + for (const message of conversation.messages) {
119 + tokens += (message.usage?.inputTokens ?? 0) + (message.usage?.outputTokens ?? 0)
120 + cost += message.estimatedCost ?? 0
121 + }
122 + return { tokens, cost }
123 + }, [conversation])
124 +
125 + return (
126 + <div className="input-dock">
127 + <div
128 + className={`input-bar${dragging ? ' drag-target' : ''}`}
129 + onDragOver={(e) => {
130 + e.preventDefault()
131 + setDragging(true)
132 + }}
133 + onDragLeave={() => setDragging(false)}
134 + onDrop={(e) => {
135 + e.preventDefault()
136 + setDragging(false)
137 + void addFiles(e.dataTransfer.files)
138 + }}
139 + >
140 + {attachments.length > 0 && (
141 + <div className="attachment-strip">
142 + {attachments.map((attachment) => (
143 + <div key={attachment.id} className="attachment-thumb">
144 + {attachment.kind === 'image' ? (
145 + <img
146 + src={`data:${attachment.mimeType};base64,${attachment.data}`}
147 + alt={attachment.fileName}
148 + />
149 + ) : (
150 + <span>{attachment.fileName}</span>
151 + )}
152 + <button
153 + className="attachment-remove"
154 + onClick={() =>
155 + setAttachments((prev) => prev.filter((a) => a.id !== attachment.id))
156 + }
157 + >
158 + <IconX size={8} />
159 + </button>
160 + </div>
161 + ))}
162 + </div>
163 + )}
164 + <div className="input-row">
165 + <button
166 + className="icon-button"
167 + title={vision ? 'Attach image or file' : 'Attach text file'}
168 + onClick={() => fileRef.current?.click()}
169 + >
170 + <IconPaperclip />
171 + </button>
172 + <input
173 + ref={fileRef}
174 + type="file"
175 + multiple
176 + hidden
177 + onChange={(e) => {
178 + if (e.target.files) void addFiles(e.target.files)
179 + e.target.value = ''
180 + }}
181 + />
182 + <textarea
183 + ref={editorRef}
184 + className="input-editor"
185 + placeholder="Message…"
186 + rows={1}
187 + value={draft}
188 + data-input-editor
189 + onChange={(e) => setDraft(e.target.value)}
190 + onKeyDown={onKeyDown}
191 + onPaste={onPaste}
192 + />
193 + <button className="icon-button" title="Parameters" onClick={onOpenParams}>
194 + <IconSliders />
195 + </button>
196 + {streaming ? (
197 + <button
198 + className="send-button stop"
199 + title="Stop (⌘.)"
200 + onClick={() => stop(conversation.id)}
201 + >
202 + <IconStop />
203 + </button>
204 + ) : (
205 + <button
206 + className="send-button"
207 + title="Send (⌘↩)"
208 + disabled={draft.trim() === '' && attachments.length === 0}
209 + onClick={doSend}
210 + >
211 + <IconSend />
212 + </button>
213 + )}
214 + </div>
215 + <div className="input-hud">
216 + {model && (
217 + <>
218 + <div className="context-bar" title="Context window usage">
219 + <div
220 + className={`context-bar-fill${contextUsage > 0.8 ? ' warn' : ''}`}
221 + style={{ width: `${Math.max(2, contextUsage * 100)}%` }}
222 + />
223 + </div>
224 + <span>{Math.round(contextUsage * 100)}% ctx</span>
225 + </>
226 + )}
227 + {totalTokens.tokens > 0 && (
228 + <span>
229 + {totalTokens.tokens.toLocaleString()} tok
230 + {totalTokens.cost > 0 ? ` · ~$${totalTokens.cost.toFixed(4)}` : ''}
231 + </span>
232 + )}
233 + </div>
234 + </div>
235 + </div>
236 + )
237 +}
238 +
239 +function fileToBase64(file: File): Promise<string> {
240 + return new Promise((resolve, reject) => {
241 + const reader = new FileReader()
242 + reader.onload = () => {
243 + const result = reader.result as string
244 + resolve(result.slice(result.indexOf(',') + 1))
245 + }
246 + reader.onerror = () => reject(reader.error ?? new Error('read failed'))
247 + reader.readAsDataURL(file)
248 + })
249 +}
added src/components/Markdown.tsx +89 −0
@@ -0,0 +1,89 @@
1 +/*
2 + * Markdown.tsx
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Assistant message rendering: GitHub-flavored Markdown with syntax-
9 + * highlighted code blocks (language label, copy, wrap toggle) mapped to the
10 + * Zyquo syntax tokens. Memoized — re-parsing only when text changes.
11 + */
12 +
13 +import { memo, useState, type ReactNode } from 'react'
14 +import ReactMarkdown from 'react-markdown'
15 +import remarkGfm from 'remark-gfm'
16 +import rehypeHighlight from 'rehype-highlight'
17 +import { IconCheck, IconCopy } from './icons'
18 +
19 +function extractText(node: ReactNode): string {
20 + if (typeof node === 'string') return node
21 + if (Array.isArray(node)) return node.map(extractText).join('')
22 + if (node && typeof node === 'object' && 'props' in node) {
23 + return extractText((node as { props: { children?: ReactNode } }).props.children)
24 + }
25 + return ''
26 +}
27 +
28 +function CodeBlock({ className, children }: { className?: string; children?: ReactNode }) {
29 + const [copied, setCopied] = useState(false)
30 + const [wrap, setWrap] = useState(false)
31 + const language = /language-(\w+)/.exec(className ?? '')?.[1] ?? ''
32 +
33 + const copy = () => {
34 + void navigator.clipboard.writeText(extractText(children).replace(/\n$/, ''))
35 + setCopied(true)
36 + setTimeout(() => setCopied(false), 1200)
37 + }
38 +
39 + return (
40 + <div className="code-block">
41 + <div className="code-block-header">
42 + <span>{language || 'text'}</span>
43 + <span className={`code-block-actions${copied ? ' confirming' : ''}`}>
44 + <button className="code-block-copy" onClick={() => setWrap((w) => !w)}>
45 + {wrap ? 'unwrap' : 'wrap'}
46 + </button>
47 + <button className={`code-block-copy${copied ? ' ok' : ''}`} onClick={copy}>
48 + {copied ? <IconCheck size={10} /> : <IconCopy size={10} />} {copied ? 'copied' : 'copy'}
49 + </button>
50 + </span>
51 + </div>
52 + <pre className={wrap ? 'wrap' : ''}>
53 + <code className={className}>{children}</code>
54 + </pre>
55 + </div>
56 + )
57 +}
58 +
59 +export const Markdown = memo(function Markdown({ text }: { text: string }) {
60 + return (
61 + <div className="markdown">
62 + <ReactMarkdown
63 + remarkPlugins={[remarkGfm]}
64 + rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
65 + components={{
66 + pre: ({ children }) => <>{children}</>,
67 + code: ({ className, children, ...props }) => {
68 + const inline = !className && typeof children === 'string' && !children.includes('\n')
69 + if (inline) {
70 + return (
71 + <code className={className} {...props}>
72 + {children}
73 + </code>
74 + )
75 + }
76 + return <CodeBlock className={className}>{children}</CodeBlock>
77 + },
78 + a: ({ href, children }) => (
79 + <a href={href} target="_blank" rel="noreferrer noopener">
80 + {children}
81 + </a>
82 + ),
83 + }}
84 + >
85 + {text}
86 + </ReactMarkdown>
87 + </div>
88 + )
89 +})
added src/components/MessageBubble.tsx +305 −0
@@ -0,0 +1,305 @@
1 +/*
2 + * MessageBubble.tsx
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * One chat turn: user right (accent-subtle) / assistant left (surface +
9 + * hairline + provider avatar). Markdown body, collapsible reasoning,
10 + * citation chips, attachments, error note, hover metadata + actions
11 + * (copy, edit & resend, regenerate, quote, branch, read-aloud, delete),
12 + * variant switcher, streaming caret.
13 + */
14 +
15 +import { memo, useState } from 'react'
16 +import { costLabel } from '../features/catalogHelpers'
17 +import { useStore } from '../state/store'
18 +import type { Message } from '../types'
19 +import { Markdown } from './Markdown'
20 +import {
21 + IconBranch,
22 + IconCheck,
23 + IconChevronRight,
24 + IconCopy,
25 + IconEdit,
26 + IconQuote,
27 + IconRefresh,
28 + IconSpeaker,
29 + IconTrash,
30 + IconX,
31 + ProviderGlyph,
32 +} from './icons'
33 +
34 +function timeLabel(timestamp: number): string {
35 + return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
36 +}
37 +
38 +export const MessageBubble = memo(function MessageBubble({
39 + conversationID,
40 + message,
41 + streaming,
42 + onQuote,
43 +}: {
44 + conversationID: string
45 + message: Message
46 + streaming: boolean
47 + onQuote: (text: string) => void
48 +}) {
49 + const regenerate = useStore((s) => s.regenerate)
50 + const editAndResend = useStore((s) => s.editAndResend)
51 + const deleteMessage = useStore((s) => s.deleteMessage)
52 + const branchFrom = useStore((s) => s.branchFrom)
53 + const setActiveVariant = useStore((s) => s.setActiveVariant)
54 + const toast = useStore((s) => s.toast)
55 + const [reasoningOpen, setReasoningOpen] = useState(false)
56 + const [editing, setEditing] = useState(false)
57 + const [draft, setDraft] = useState(message.text)
58 + const [copied, setCopied] = useState(false)
59 +
60 + const isUser = message.role === 'user'
61 + const variants = message.variants ?? []
62 + const activeVariant =
63 + message.activeVariant !== undefined ? variants[message.activeVariant] : undefined
64 + const shownText = activeVariant?.text ?? message.text
65 + const shownReasoning = activeVariant?.reasoning ?? message.reasoning
66 + const shownCitations = activeVariant?.citations ?? message.citations
67 + const shownUsage = activeVariant?.usage ?? message.usage
68 + const shownCost = activeVariant?.estimatedCost ?? message.estimatedCost
69 +
70 + const copy = () => {
71 + void navigator.clipboard.writeText(shownText)
72 + setCopied(true)
73 + setTimeout(() => setCopied(false), 1200)
74 + }
75 +
76 + const readAloud = () => {
77 + if (!('speechSynthesis' in window)) {
78 + toast('Speech synthesis not available in this browser', 'error')
79 + return
80 + }
81 + speechSynthesis.cancel()
82 + speechSynthesis.speak(new SpeechSynthesisUtterance(shownText))
83 + }
84 +
85 + return (
86 + <div className={`turn ${isUser ? 'user' : 'assistant'}`}>
87 + {!isUser && (
88 + <div className="avatar">
89 + <ProviderGlyph provider={message.provider ?? 'custom'} size={12} />
90 + </div>
91 + )}
92 + <div className="turn-body">
93 + <div className="bubble">
94 + {(message.attachments ?? []).filter((a) => a.kind === 'image').length > 0 && (
95 + <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 6 }}>
96 + {(message.attachments ?? [])
97 + .filter((a) => a.kind === 'image')
98 + .map((a) => (
99 + <img
100 + key={a.id}
101 + src={`data:${a.mimeType};base64,${a.data}`}
102 + alt={a.fileName}
103 + style={{ maxWidth: 160, maxHeight: 120, borderRadius: 6, objectFit: 'cover' }}
104 + />
105 + ))}
106 + </div>
107 + )}
108 + {(message.attachments ?? []).filter((a) => a.kind === 'textFile').length > 0 && (
109 + <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>
110 + {(message.attachments ?? [])
111 + .filter((a) => a.kind === 'textFile')
112 + .map((a) => (
113 + <span key={a.id} className="badge">
114 + {a.fileName}
115 + </span>
116 + ))}
117 + </div>
118 + )}
119 +
120 + {shownReasoning !== undefined && shownReasoning !== '' && (
121 + <div className="reasoning-section">
122 + <button className="reasoning-toggle" onClick={() => setReasoningOpen((o) => !o)}>
123 + <span className={`chev${reasoningOpen ? ' open' : ''}`}>
124 + <IconChevronRight />
125 + </span>
126 + {streaming && shownText === '' ? 'Thinking…' : 'Thought process'}
127 + </button>
128 + {reasoningOpen && <div className="reasoning-body">{shownReasoning}</div>}
129 + </div>
130 + )}
131 +
132 + {editing ? (
133 + <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
134 + <textarea
135 + className="text-input"
136 + rows={Math.min(8, Math.max(2, draft.split('\n').length))}
137 + value={draft}
138 + onChange={(e) => setDraft(e.target.value)}
139 + autoFocus
140 + />
141 + <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
142 + <button className="button" onClick={() => setEditing(false)}>
143 + Cancel
144 + </button>
145 + <button
146 + className="button primary"
147 + disabled={draft.trim() === ''}
148 + onClick={() => {
149 + setEditing(false)
150 + void editAndResend(conversationID, message.id, draft)
151 + }}
152 + >
153 + Resend
154 + </button>
155 + </div>
156 + </div>
157 + ) : isUser ? (
158 + shownText
159 + ) : (
160 + <>
161 + <Markdown text={shownText} />
162 + {streaming && <span className="streaming-caret" />}
163 + </>
164 + )}
165 +
166 + {shownCitations && shownCitations.length > 0 && (
167 + <div className="citations">
168 + {shownCitations.map((citation) => (
169 + <a
170 + key={citation.index}
171 + className="citation-chip"
172 + href={citation.url}
173 + target="_blank"
174 + rel="noreferrer noopener"
175 + title={citation.url}
176 + >
177 + <span className="num">{citation.index}</span>
178 + <span className="title">
179 + {citation.title ?? new URL(citation.url).hostname}
180 + </span>
181 + </a>
182 + ))}
183 + </div>
184 + )}
185 +
186 + {message.errorText && (
187 + <div className="error-note">
188 + <span>⚠</span>
189 + <span>{message.errorText}</span>
190 + </div>
191 + )}
192 + </div>
193 +
194 + <div className={`meta-row${streaming ? ' always' : ''}`}>
195 + {!isUser && variants.length > 0 && (
196 + <span className="variant-switch">
197 + <button
198 + className="meta-action"
199 + title="Previous variant"
200 + onClick={() => {
201 + const current = message.activeVariant ?? -1
202 + setActiveVariant(conversationID, message.id, Math.max(-1, current - 1) as number)
203 + }}
204 + >
205 +
206 + </button>
207 + {(message.activeVariant ?? -1) + 2}/{variants.length + 1}
208 + <button
209 + className="meta-action"
210 + title="Next variant"
211 + onClick={() => {
212 + const current = message.activeVariant ?? -1
213 + setActiveVariant(
214 + conversationID,
215 + message.id,
216 + Math.min(variants.length - 1, current + 1)
217 + )
218 + }}
219 + >
220 +
221 + </button>
222 + </span>
223 + )}
224 + {!isUser && (
225 + <>
226 + <button className="meta-action" title="Copy" onClick={copy}>
227 + {copied ? <IconCheck size={10} /> : <IconCopy size={10} />}
228 + </button>
229 + <button
230 + className="meta-action"
231 + title="Regenerate"
232 + onClick={() => void regenerate(conversationID, message.id)}
233 + >
234 + <IconRefresh size={10} />
235 + </button>
236 + <button className="meta-action" title="Quote reply" onClick={() => onQuote(shownText)}>
237 + <IconQuote size={10} />
238 + </button>
239 + <button
240 + className="meta-action"
241 + title="Branch from here"
242 + onClick={() => branchFrom(conversationID, message.id)}
243 + >
244 + <IconBranch size={10} />
245 + </button>
246 + <button className="meta-action" title="Read aloud" onClick={readAloud}>
247 + <IconSpeaker size={10} />
248 + </button>
249 + </>
250 + )}
251 + {isUser && (
252 + <>
253 + <button className="meta-action" title="Copy" onClick={copy}>
254 + {copied ? <IconCheck size={10} /> : <IconCopy size={10} />}
255 + </button>
256 + <button
257 + className="meta-action"
258 + title="Edit & resend"
259 + onClick={() => {
260 + setDraft(message.text)
261 + setEditing(true)
262 + }}
263 + >
264 + <IconEdit size={10} />
265 + </button>
266 + </>
267 + )}
268 + <button
269 + className="meta-action"
270 + title="Delete"
271 + onClick={() => deleteMessage(conversationID, message.id)}
272 + >
273 + <IconTrash size={10} />
274 + </button>
275 + <span>{timeLabel(message.createdAt)}</span>
276 + {shownUsage && (
277 + <span>
278 + {shownUsage.inputTokens}→{shownUsage.outputTokens} tok
279 + </span>
280 + )}
281 + {shownCost !== undefined && shownCost > 0 && <span>{costLabel(shownCost)}</span>}
282 + {streaming && <StopHint conversationID={conversationID} />}
283 + </div>
284 + </div>
285 + </div>
286 + )
287 +})
288 +
289 +function StopHint({ conversationID }: { conversationID: string }) {
290 + const stop = useStore((s) => s.stop)
291 + const streaming = useStore((s) => s.streaming[conversationID])
292 + if (!streaming) return null
293 + const elapsed = (Date.now() - streaming.startedAt) / 1000
294 + const tps = elapsed > 0 ? Math.round(streaming.outputChars / 4 / elapsed) : 0
295 + return (
296 + <>
297 + <span>
298 + {elapsed.toFixed(0)}s{tps > 0 ? ` · ~${tps} tok/s` : ''}
299 + </span>
300 + <button className="meta-action" onClick={() => stop(conversationID)}>
301 + <IconX size={9} /> stop
302 + </button>
303 + </>
304 + )
305 +}
added src/components/ModelMenu.tsx +331 −0
@@ -0,0 +1,331 @@
1 +/*
2 + * ModelMenu.tsx
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * The Model Menu — command-palette-style picker (also opened with ⌘K via the
9 + * command palette's model section). Instant fuzzy search, capability filter
10 + * chips, Favorites + Recents groups, provider sections (keyed providers
11 + * first), capability/context/price badges, per-row star, "not configured"
12 + * affordance, keyboard navigation.
13 + */
14 +
15 +import { useEffect, useMemo, useRef, useState } from 'react'
16 +import { CATALOG, priceBadge } from '../features/catalogHelpers'
17 +import { PROVIDER_META } from '../providers/registry'
18 +import { useStore } from '../state/store'
19 +import { contextBadge, type AIModel, type Provider } from '../types'
20 +import {
21 + IconBrain,
22 + IconEye,
23 + IconSearch,
24 + IconStar,
25 + IconWrench,
26 + ProviderGlyph,
27 +} from './icons'
28 +
29 +const FILTERS = [
30 + { key: 'vision', label: 'Vision' },
31 + { key: 'tools', label: 'Tools' },
32 + { key: 'reasoning', label: 'Reasoning' },
33 + { key: 'jsonMode', label: 'JSON' },
34 + { key: 'long', label: 'Long context' },
35 + { key: 'cheap', label: 'Cheapest' },
36 +] as const
37 +
38 +type FilterKey = (typeof FILTERS)[number]['key']
39 +
40 +/** Simple subsequence fuzzy match; returns a score (lower = better) or null. */
41 +function fuzzyScore(query: string, target: string): number | null {
42 + const q = query.toLowerCase()
43 + const t = target.toLowerCase()
44 + if (q === '') return 0
45 + const direct = t.indexOf(q)
46 + if (direct !== -1) return direct
47 + let qi = 0
48 + let gaps = 0
49 + let last = -1
50 + for (let ti = 0; ti < t.length && qi < q.length; ti++) {
51 + if (t[ti] === q[qi]) {
52 + if (last !== -1) gaps += ti - last - 1
53 + last = ti
54 + qi++
55 + }
56 + }
57 + return qi === q.length ? 100 + gaps : null
58 +}
59 +
60 +interface Row {
61 + model: AIModel
62 + section: string
63 +}
64 +
65 +export default function ModelMenu({
66 + onPick,
67 + onClose,
68 + currentID,
69 + title = 'Switch model',
70 +}: {
71 + onPick: (model: AIModel, scope: 'conversation' | 'default' | 'message') => void
72 + onClose: () => void
73 + currentID?: string
74 + title?: string
75 +}) {
76 + const settings = useStore((s) => s.settings)
77 + const updateSettings = useStore((s) => s.updateSettings)
78 + const keyedProviders = useStore((s) => s.keyedProviders)
79 + const openSettings = useStore((s) => s.openSettings)
80 + const [query, setQuery] = useState('')
81 + const [filters, setFilters] = useState<Set<FilterKey>>(new Set())
82 + const [highlight, setHighlight] = useState(0)
83 + const inputRef = useRef<HTMLInputElement>(null)
84 + const listRef = useRef<HTMLDivElement>(null)
85 +
86 + useEffect(() => inputRef.current?.focus(), [])
87 +
88 + const favoriteSet = useMemo(() => new Set(settings.favoriteModelIDs), [settings.favoriteModelIDs])
89 +
90 + const rows: Row[] = useMemo(() => {
91 + let models = [...CATALOG]
92 + // Capability filter chips
93 + if (filters.has('vision')) models = models.filter((m) => m.capabilities.vision)
94 + if (filters.has('tools')) models = models.filter((m) => m.capabilities.tools)
95 + if (filters.has('reasoning')) models = models.filter((m) => m.capabilities.reasoning)
96 + if (filters.has('jsonMode')) models = models.filter((m) => m.capabilities.jsonMode)
97 + if (filters.has('long')) models = models.filter((m) => m.contextWindow >= 400_000)
98 + if (filters.has('cheap')) {
99 + models = models
100 + .filter((m) => m.pricing)
101 + .sort((a, b) => (a.pricing?.outputPerMTok ?? 0) - (b.pricing?.outputPerMTok ?? 0))
102 + .slice(0, 20)
103 + }
104 + // Fuzzy search over name + id + provider
105 + if (query !== '') {
106 + const scored = models
107 + .map((m) => {
108 + const scores = [
109 + fuzzyScore(query, m.displayName),
110 + fuzzyScore(query, m.id),
111 + fuzzyScore(query, PROVIDER_META[m.provider].displayName),
112 + ].filter((s): s is number => s !== null)
113 + return scores.length > 0 ? { m, score: Math.min(...scores) } : null
114 + })
115 + .filter((x): x is { m: AIModel; score: number } => x !== null)
116 + .sort((a, b) => a.score - b.score)
117 + return scored.map(({ m }) => ({ model: m, section: 'Results' }))
118 + }
119 + // Grouped: aliases → favorites → recents → providers (keyed first)
120 + const out: Row[] = []
121 + for (const [alias, ref] of Object.entries(settings.aliases)) {
122 + const model = models.find((m) => m.provider === ref.provider && m.id === ref.modelID)
123 + if (model) out.push({ model, section: `Alias: ${alias}` })
124 + }
125 + const favorites = models.filter((m) => favoriteSet.has(m.id))
126 + for (const model of favorites) out.push({ model, section: 'Favorites' })
127 + const recents = settings.recentModelIDs
128 + .map((id) => models.find((m) => m.id === id))
129 + .filter((m): m is AIModel => m !== undefined && !favoriteSet.has(m.id))
130 + .slice(0, 5)
131 + for (const model of recents) out.push({ model, section: 'Recents' })
132 + const providerOrder: Provider[] = [...new Set(models.map((m) => m.provider))].sort((a, b) => {
133 + const ak = keyedProviders.includes(a) ? 0 : 1
134 + const bk = keyedProviders.includes(b) ? 0 : 1
135 + if (ak !== bk) return ak - bk
136 + return PROVIDER_META[a].displayName.localeCompare(PROVIDER_META[b].displayName)
137 + })
138 + for (const provider of providerOrder) {
139 + const list = models
140 + .filter((m) => m.provider === provider)
141 + .sort((a, b) => rankOf(a) - rankOf(b))
142 + for (const model of list) {
143 + out.push({ model, section: PROVIDER_META[provider].displayName })
144 + }
145 + }
146 + return out
147 +
148 + function rankOf(m: AIModel): number {
149 + if (favoriteSet.has(m.id)) return 0
150 + if (m.isRecommended) return 1
151 + if (m.isLegacy) return 3
152 + return 2
153 + }
154 + }, [query, filters, settings.aliases, settings.recentModelIDs, favoriteSet, keyedProviders])
155 +
156 + useEffect(() => setHighlight(0), [query, filters])
157 +
158 + const pick = (model: AIModel, scope: 'conversation' | 'default' | 'message' = 'conversation') => {
159 + updateSettings({
160 + recentModelIDs: [model.id, ...settings.recentModelIDs.filter((id) => id !== model.id)].slice(
161 + 0,
162 + 8
163 + ),
164 + })
165 + onPick(model, scope)
166 + onClose()
167 + }
168 +
169 + const onKeyDown = (e: React.KeyboardEvent) => {
170 + if (e.key === 'ArrowDown') {
171 + e.preventDefault()
172 + setHighlight((h) => Math.min(h + 1, rows.length - 1))
173 + scrollToHighlight(1)
174 + } else if (e.key === 'ArrowUp') {
175 + e.preventDefault()
176 + setHighlight((h) => Math.max(h - 1, 0))
177 + scrollToHighlight(-1)
178 + } else if (e.key === 'Enter') {
179 + e.preventDefault()
180 + const row = rows[highlight]
181 + if (row) pick(row.model, e.metaKey || e.ctrlKey ? 'default' : 'conversation')
182 + } else if (e.key === 'Escape') {
183 + onClose()
184 + }
185 + }
186 +
187 + const scrollToHighlight = (dir: number) => {
188 + requestAnimationFrame(() => {
189 + const el = listRef.current?.querySelector('.model-row.highlighted')
190 + el?.scrollIntoView({ block: dir > 0 ? 'nearest' : 'nearest' })
191 + })
192 + }
193 +
194 + const toggleFavorite = (e: React.MouseEvent, model: AIModel) => {
195 + e.stopPropagation()
196 + const next = favoriteSet.has(model.id)
197 + ? settings.favoriteModelIDs.filter((id) => id !== model.id)
198 + : [...settings.favoriteModelIDs, model.id]
199 + updateSettings({ favoriteModelIDs: next })
200 + }
201 +
202 + let lastSection = ''
203 + let rowIndex = -1
204 +
205 + return (
206 + <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
207 + <div className="panel" role="dialog" aria-label={title} onKeyDown={onKeyDown}>
208 + <div className="panel-search">
209 + <IconSearch size={14} />
210 + <input
211 + ref={inputRef}
212 + placeholder="Search models, providers, capabilities…"
213 + value={query}
214 + onChange={(e) => setQuery(e.target.value)}
215 + />
216 + </div>
217 + <div className="panel-filters">
218 + {FILTERS.map((f) => (
219 + <button
220 + key={f.key}
221 + className={`filter-chip${filters.has(f.key) ? ' active' : ''}`}
222 + onClick={() =>
223 + setFilters((prev) => {
224 + const next = new Set(prev)
225 + if (next.has(f.key)) next.delete(f.key)
226 + else next.add(f.key)
227 + return next
228 + })
229 + }
230 + >
231 + {f.label}
232 + </button>
233 + ))}
234 + </div>
235 + <div className="panel-list" ref={listRef}>
236 + {rows.length === 0 && <div className="panel-empty">No models match.</div>}
237 + {rows.map((row) => {
238 + rowIndex++
239 + const index = rowIndex
240 + const header =
241 + row.section !== lastSection ? (
242 + <div className="panel-section-header" key={`h-${row.section}`}>
243 + {row.section}
244 + </div>
245 + ) : null
246 + lastSection = row.section
247 + const model = row.model
248 + const keyed = keyedProviders.includes(model.provider)
249 + return (
250 + <div key={`${row.section}-${model.provider}-${model.id}`}>
251 + {header}
252 + <button
253 + className={`model-row${index === highlight ? ' highlighted' : ''}${
254 + model.id === currentID ? ' current' : ''
255 + }`}
256 + onClick={(e) =>
257 + pick(model, e.metaKey || e.ctrlKey ? 'default' : 'conversation')
258 + }
259 + onMouseMove={() => setHighlight(index)}
260 + >
261 + <span className="glyph">
262 + <ProviderGlyph provider={model.provider} />
263 + </span>
264 + <span className="model-row-name">{model.displayName}</span>
265 + <span className="model-row-provider">
266 + {PROVIDER_META[model.provider].displayName}
267 + </span>
268 + {model.isRecommended && <span className="badge accent">Featured</span>}
269 + {model.isLegacy && <span className="badge">Legacy</span>}
270 + {!keyed && (
271 + <button
272 + className="badge warn"
273 + title="No API key configured — add one"
274 + onClick={(e) => {
275 + e.stopPropagation()
276 + onClose()
277 + openSettings('providers')
278 + }}
279 + >
280 + Add key
281 + </button>
282 + )}
283 + <span className="model-row-badges">
284 + {model.capabilities.vision && (
285 + <span className="capability-badge" title="Vision">
286 + <IconEye />
287 + </span>
288 + )}
289 + {model.capabilities.reasoning && (
290 + <span className="capability-badge" title="Reasoning">
291 + <IconBrain />
292 + </span>
293 + )}
294 + {model.capabilities.tools && (
295 + <span className="capability-badge" title="Tools">
296 + <IconWrench />
297 + </span>
298 + )}
299 + <span>{contextBadge(model)}</span>
300 + <span title="USD per 1M tokens in / out">{priceBadge(model)}</span>
301 + <button
302 + className={`star${favoriteSet.has(model.id) ? ' active' : ''}`}
303 + title="Favorite"
304 + onClick={(e) => toggleFavorite(e, model)}
305 + >
306 + <IconStar filled={favoriteSet.has(model.id)} />
307 + </button>
308 + </span>
309 + </button>
310 + </div>
311 + )
312 + })}
313 + </div>
314 + <div className="panel-footer">
315 + <span>
316 + <kbd>↑↓</kbd> navigate
317 + </span>
318 + <span>
319 + <kbd>↵</kbd> use for this chat
320 + </span>
321 + <span>
322 + <kbd>⌘↵</kbd> set as default
323 + </span>
324 + <span>
325 + <kbd>esc</kbd> close
326 + </span>
327 + </div>
328 + </div>
329 + </div>
330 + )
331 +}
added src/components/ParamsPanel.tsx +147 −0
@@ -0,0 +1,147 @@
1 +/*
2 + * ParamsPanel.tsx
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Per-conversation parameter editor. Only shows controls the active model's
9 + * ParameterSupport allows; every control is gated by an enable checkbox —
10 + * unchecked means "provider default" and the key is omitted from requests
11 + * entirely (native behavior).
12 + */
13 +
14 +import { findModel } from '../features/catalogHelpers'
15 +import { useStore } from '../state/store'
16 +import type { ChatParameters, Conversation } from '../types'
17 +
18 +export default function ParamsPanel({
19 + conversation,
20 + onClose,
21 +}: {
22 + conversation: Conversation
23 + onClose: () => void
24 +}) {
25 + const updateConversation = useStore((s) => s.updateConversation)
26 + const model = findModel(conversation.provider, conversation.modelID)
27 + const support = model?.parameterSupport
28 + const params = conversation.parameters
29 +
30 + const patch = (p: Partial<ChatParameters>) => {
31 + const merged = { ...params, ...p }
32 + for (const key of Object.keys(merged) as (keyof ChatParameters)[]) {
33 + if (merged[key] === undefined) delete merged[key]
34 + }
35 + updateConversation(conversation.id, { parameters: merged })
36 + }
37 +
38 + const slider = (
39 + label: string,
40 + key: 'temperature' | 'topP' | 'frequencyPenalty' | 'presencePenalty',
41 + min: number,
42 + max: number,
43 + step: number,
44 + fallback: number
45 + ) => (
46 + <div className="settings-row" key={key}>
47 + <label style={{ display: 'flex', alignItems: 'center', gap: 6, width: 150 }}>
48 + <input
49 + type="checkbox"
50 + checked={params[key] !== undefined}
51 + onChange={(e) => patch({ [key]: e.target.checked ? fallback : undefined })}
52 + />
53 + {label}
54 + </label>
55 + {params[key] !== undefined ? (
56 + <>
57 + <input
58 + type="range"
59 + min={min}
60 + max={max}
61 + step={step}
62 + value={params[key]}
63 + style={{ flex: 1, accentColor: 'var(--z-accent)' }}
64 + onChange={(e) => patch({ [key]: Number(e.target.value) })}
65 + />
66 + <span style={{ width: 40, textAlign: 'right', fontSize: 12 }}>
67 + {params[key]?.toFixed(2)}
68 + </span>
69 + </>
70 + ) : (
71 + <span className="settings-note" style={{ marginLeft: 'auto' }}>
72 + default
73 + </span>
74 + )}
75 + </div>
76 + )
77 +
78 + return (
79 + <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
80 + <div className="panel" style={{ width: 420 }}>
81 + <div className="settings-content">
82 + <h3>Parameters — {model?.displayName ?? conversation.modelID}</h3>
83 + {support?.temperature && slider('Temperature', 'temperature', 0, 2, 0.05, 1)}
84 + {support?.topP && slider('Top P', 'topP', 0, 1, 0.01, 1)}
85 + {support?.frequencyPenalty &&
86 + slider('Frequency penalty', 'frequencyPenalty', -2, 2, 0.1, 0)}
87 + {support?.presencePenalty && slider('Presence penalty', 'presencePenalty', -2, 2, 0.1, 0)}
88 + <div className="settings-row">
89 + <span style={{ width: 150 }}>Max tokens</span>
90 + <input
91 + className="number-input"
92 + type="number"
93 + min={1}
94 + placeholder="default"
95 + style={{ width: 100 }}
96 + value={params.maxTokens ?? ''}
97 + onChange={(e) =>
98 + patch({ maxTokens: e.target.value === '' ? undefined : Number(e.target.value) })
99 + }
100 + />
101 + </div>
102 + {support?.reasoningEffort && (
103 + <div className="settings-row">
104 + <span style={{ width: 150 }}>Reasoning effort</span>
105 + <select
106 + className="select"
107 + value={params.reasoningEffort ?? ''}
108 + onChange={(e) =>
109 + patch({
110 + reasoningEffort:
111 + e.target.value === ''
112 + ? undefined
113 + : (e.target.value as 'low' | 'medium' | 'high'),
114 + })
115 + }
116 + >
117 + <option value="">Default</option>
118 + <option value="low">Low</option>
119 + <option value="medium">Medium</option>
120 + <option value="high">High</option>
121 + </select>
122 + </div>
123 + )}
124 + {support?.thinkingToggle && (
125 + <div className="settings-row">
126 + <label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
127 + <input
128 + type="checkbox"
129 + checked={params.thinkingEnabled ?? false}
130 + onChange={(e) =>
131 + patch({ thinkingEnabled: e.target.checked ? true : undefined })
132 + }
133 + />
134 + Extended thinking
135 + </label>
136 + </div>
137 + )}
138 + <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
139 + <button className="button" onClick={onClose}>
140 + Done
141 + </button>
142 + </div>
143 + </div>
144 + </div>
145 + </div>
146 + )
147 +}
added src/components/SettingsModal.tsx +596 −0
@@ -0,0 +1,596 @@
1 +/*
2 + * SettingsModal.tsx
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Settings: Providers & Keys (masked fields, Test with status dot + latency,
9 + * proxy base URL, honesty notice), Models (catalog browser, favorites,
10 + * default), Appearance (light default / dark / system, accents, font size),
11 + * Data (export/import/clear), Advanced (passphrase lock, default system
12 + * prompt, streaming toggle).
13 + */
14 +
15 +import { useMemo, useRef, useState } from 'react'
16 +import { CATALOG, priceBadge } from '../features/catalogHelpers'
17 +import { downloadBackup } from '../features/exporters'
18 +import { testKey } from '../providers/testKey'
19 +import { PROVIDER_META } from '../providers/registry'
20 +import { useStore } from '../state/store'
21 +import { importAll, isExportedData, clearEverything } from '../storage/backup'
22 +import { clearConversations } from '../storage/conversations'
23 +import { getKey, maskedKey, removeKey, setKey } from '../storage/keys'
24 +import { disableVault, enableVault, isVaultEnabled, persistVault } from '../storage/vault'
25 +import { contextBadge, BUILT_IN_PROVIDERS, type Provider } from '../types'
26 +import {
27 + IconBrain,
28 + IconEye,
29 + IconStar,
30 + IconWrench,
31 + IconX,
32 + ProviderGlyph,
33 +} from './icons'
34 +
35 +const TABS = [
36 + { id: 'providers', label: 'Providers & Keys' },
37 + { id: 'models', label: 'Models' },
38 + { id: 'appearance', label: 'Appearance' },
39 + { id: 'data', label: 'Data' },
40 + { id: 'advanced', label: 'Advanced' },
41 +]
42 +
43 +type TestState = { status: 'idle' | 'testing' | 'ok' | 'fail'; detail?: string }
44 +
45 +export default function SettingsModal() {
46 + const settingsTab = useStore((s) => s.settingsTab)
47 + const closeSettings = useStore((s) => s.closeSettings)
48 + const [tab, setTab] = useState(settingsTab)
49 +
50 + return (
51 + <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && closeSettings()}>
52 + <div className="panel settings-panel" role="dialog" aria-label="Settings">
53 + <div className="panel-search" style={{ justifyContent: 'space-between' }}>
54 + <strong style={{ fontSize: 14 }}>Settings</strong>
55 + <button className="icon-button" onClick={closeSettings}>
56 + <IconX />
57 + </button>
58 + </div>
59 + <div className="settings-body">
60 + <nav className="settings-tabs">
61 + {TABS.map((t) => (
62 + <button
63 + key={t.id}
64 + className={`settings-tab${tab === t.id ? ' active' : ''}`}
65 + onClick={() => setTab(t.id)}
66 + >
67 + {t.label}
68 + </button>
69 + ))}
70 + </nav>
71 + <div className="settings-content">
72 + {tab === 'providers' && <ProvidersTab />}
73 + {tab === 'models' && <ModelsTab />}
74 + {tab === 'appearance' && <AppearanceTab />}
75 + {tab === 'data' && <DataTab />}
76 + {tab === 'advanced' && <AdvancedTab />}
77 + </div>
78 + </div>
79 + </div>
80 + </div>
81 + )
82 +}
83 +
84 +function ProvidersTab() {
85 + const refreshKeyedProviders = useStore((s) => s.refreshKeyedProviders)
86 + const settings = useStore((s) => s.settings)
87 + const updateSettings = useStore((s) => s.updateSettings)
88 + const [drafts, setDrafts] = useState<Partial<Record<Provider, string>>>({})
89 + const [tests, setTests] = useState<Partial<Record<Provider, TestState>>>({})
90 + const [proxyFor, setProxyFor] = useState<Provider | null>(null)
91 + const [, forceRender] = useState(0)
92 +
93 + const saveDraft = (provider: Provider) => {
94 + const draft = drafts[provider]
95 + if (draft !== undefined && draft !== '') {
96 + setKey(provider, draft.trim())
97 + setDrafts((d) => ({ ...d, [provider]: undefined }))
98 + refreshKeyedProviders()
99 + forceRender((n) => n + 1)
100 + }
101 + }
102 +
103 + const runTest = async (provider: Provider) => {
104 + saveDraft(provider)
105 + const key = getKey(provider)
106 + if (!key) {
107 + setTests((t) => ({ ...t, [provider]: { status: 'fail', detail: 'No key' } }))
108 + return
109 + }
110 + setTests((t) => ({ ...t, [provider]: { status: 'testing' } }))
111 + try {
112 + const ms = await testKey(provider, key, settings.proxyBaseURLs[provider])
113 + setTests((t) => ({
114 + ...t,
115 + [provider]: { status: 'ok', detail: `Verified · ${Math.round(ms)} ms` },
116 + }))
117 + } catch (err) {
118 + setTests((t) => ({
119 + ...t,
120 + [provider]: { status: 'fail', detail: err instanceof Error ? err.message : String(err) },
121 + }))
122 + }
123 + }
124 +
125 + return (
126 + <div>
127 + <div className="first-run-note" style={{ marginBottom: 16 }}>
128 + <span>
129 + <strong>Your keys stay in this browser.</strong> They're stored in localStorage on this
130 + device and sent only to the provider you call. Anyone with access to this browser
131 + profile (or any script that runs on this page) could read them — that's inherent to a
132 + backend-less app. Use the optional passphrase lock in Advanced for at-rest encryption.
133 + </span>
134 + </div>
135 + {BUILT_IN_PROVIDERS.map((provider) => {
136 + const saved = getKey(provider)
137 + const test = tests[provider] ?? { status: 'idle' }
138 + const proxy = settings.proxyBaseURLs[provider]
139 + return (
140 + <div className="settings-row" key={provider}>
141 + <span className="provider-name">
142 + <span style={{ color: 'var(--z-accent)', display: 'inline-flex' }}>
143 + <ProviderGlyph provider={provider} size={13} />
144 + </span>
145 + {PROVIDER_META[provider].displayName}
146 + <span
147 + className={`status-dot ${
148 + test.status === 'ok'
149 + ? 'ok'
150 + : test.status === 'fail'
151 + ? 'fail'
152 + : saved
153 + ? 'saved'
154 + : ''
155 + }`}
156 + />
157 + </span>
158 + <input
159 + className="key-input"
160 + type="password"
161 + placeholder={saved ? maskedKey(saved) : 'API key'}
162 + value={drafts[provider] ?? ''}
163 + onChange={(e) => setDrafts((d) => ({ ...d, [provider]: e.target.value }))}
164 + onBlur={() => saveDraft(provider)}
165 + />
166 + <button className="button" onClick={() => void runTest(provider)}>
167 + {test.status === 'testing' ? 'Testing…' : 'Test'}
168 + </button>
169 + <button
170 + className="button"
171 + title="Proxy / base URL override"
172 + onClick={() => setProxyFor(proxyFor === provider ? null : provider)}
173 + >
174 + Proxy{proxy ? ' •' : ''}
175 + </button>
176 + {saved && (
177 + <button
178 + className="icon-button danger"
179 + title="Remove key"
180 + onClick={() => {
181 + removeKey(provider)
182 + refreshKeyedProviders()
183 + setTests((t) => ({ ...t, [provider]: { status: 'idle' } }))
184 + forceRender((n) => n + 1)
185 + }}
186 + >
187 + <IconX size={10} />
188 + </button>
189 + )}
190 + {test.detail && (
191 + <span className={`settings-sub ${test.status === 'ok' ? 'ok' : 'fail'}`}>
192 + {test.detail}
193 + </span>
194 + )}
195 + {proxyFor === provider && (
196 + <div style={{ width: '100%', display: 'flex', gap: 8, alignItems: 'center' }}>
197 + <input
198 + className="text-input"
199 + style={{ flex: 1 }}
200 + placeholder={`Proxy base URL (e.g. http://localhost:8787) — default: ${PROVIDER_META[provider].defaultBaseURL}`}
201 + value={proxy ?? ''}
202 + onChange={(e) =>
203 + updateSettings({
204 + proxyBaseURLs: {
205 + ...settings.proxyBaseURLs,
206 + [provider]: e.target.value === '' ? undefined : e.target.value,
207 + },
208 + })
209 + }
210 + />
211 + </div>
212 + )}
213 + </div>
214 + )
215 + })}
216 + <p className="settings-note" style={{ marginTop: 12 }}>
217 + All 12 providers currently allow direct browser calls (verified — see docs/CORS-MATRIX.md).
218 + If a provider starts blocking CORS, set a proxy URL above or run Zyquo Router locally and
219 + point the override at it.
220 + </p>
221 + </div>
222 + )
223 +}
224 +
225 +function ModelsTab() {
226 + const settings = useStore((s) => s.settings)
227 + const updateSettings = useStore((s) => s.updateSettings)
228 + const [provider, setProvider] = useState<Provider>('openai')
229 + const favoriteSet = useMemo(() => new Set(settings.favoriteModelIDs), [settings.favoriteModelIDs])
230 + const models = useMemo(() => CATALOG.filter((m) => m.provider === provider), [provider])
231 +
232 + return (
233 + <div>
234 + <div className="settings-row" style={{ borderBottom: 'none' }}>
235 + <select
236 + className="select"
237 + value={provider}
238 + onChange={(e) => setProvider(e.target.value as Provider)}
239 + >
240 + {BUILT_IN_PROVIDERS.map((p) => (
241 + <option key={p} value={p}>
242 + {PROVIDER_META[p].displayName}
243 + </option>
244 + ))}
245 + </select>
246 + <span className="settings-note">{models.length} models</span>
247 + </div>
248 + {models.map((model) => (
249 + <div className="settings-row" key={model.id}>
250 + <button
251 + className={`star${favoriteSet.has(model.id) ? ' active' : ''}`}
252 + style={{ color: favoriteSet.has(model.id) ? 'var(--z-warning)' : 'var(--z-text-tertiary)' }}
253 + onClick={() =>
254 + updateSettings({
255 + favoriteModelIDs: favoriteSet.has(model.id)
256 + ? settings.favoriteModelIDs.filter((id) => id !== model.id)
257 + : [...settings.favoriteModelIDs, model.id],
258 + })
259 + }
260 + >
261 + <IconStar filled={favoriteSet.has(model.id)} />
262 + </button>
263 + <div style={{ minWidth: 0, flex: 1 }}>
264 + <div style={{ fontSize: 13, display: 'flex', gap: 6, alignItems: 'center' }}>
265 + {model.displayName}
266 + {model.isRecommended && <span className="badge accent">Featured</span>}
267 + {model.isLegacy && <span className="badge">Legacy</span>}
268 + {settings.defaultModelID === model.id && settings.defaultProvider === provider && (
269 + <span className="badge accent">Default</span>
270 + )}
271 + </div>
272 + <div
273 + style={{
274 + fontSize: 10,
275 + fontFamily: 'var(--z-font-mono)',
276 + color: 'var(--z-text-tertiary)',
277 + overflow: 'hidden',
278 + textOverflow: 'ellipsis',
279 + whiteSpace: 'nowrap',
280 + }}
281 + >
282 + {model.id}
283 + </div>
284 + </div>
285 + <span className="model-row-badges">
286 + {model.capabilities.vision && <IconEye />}
287 + {model.capabilities.reasoning && <IconBrain />}
288 + {model.capabilities.tools && <IconWrench />}
289 + <span>{contextBadge(model)}</span>
290 + <span>{priceBadge(model)}</span>
291 + </span>
292 + <button
293 + className="button"
294 + onClick={() => updateSettings({ defaultModelID: model.id, defaultProvider: provider })}
295 + >
296 + Set default
297 + </button>
298 + </div>
299 + ))}
300 + </div>
301 + )
302 +}
303 +
304 +function AppearanceTab() {
305 + const settings = useStore((s) => s.settings)
306 + const updateSettings = useStore((s) => s.updateSettings)
307 + return (
308 + <div>
309 + <div className="settings-section">
310 + <h3>Theme</h3>
311 + <div style={{ display: 'flex', gap: 8 }}>
312 + {(['light', 'dark', 'system'] as const).map((theme) => (
313 + <button
314 + key={theme}
315 + className={`button${settings.theme === theme ? ' primary' : ''}`}
316 + onClick={() => updateSettings({ theme })}
317 + >
318 + {theme[0]?.toUpperCase() + theme.slice(1)}
319 + </button>
320 + ))}
321 + </div>
322 + <p className="settings-note" style={{ marginTop: 6 }}>
323 + Light is the Zyquo flagship theme and the default.
324 + </p>
325 + </div>
326 + <div className="settings-section">
327 + <h3>Accent</h3>
328 + <div style={{ display: 'flex', gap: 8 }}>
329 + {(
330 + [
331 + ['indigo', '#4E6AF0'],
332 + ['graphite', '#5B6270'],
333 + ['teal', '#1D9A8F'],
334 + ['amber', '#C77D1D'],
335 + ['rose', '#C94F7C'],
336 + ] as const
337 + ).map(([accent, color]) => (
338 + <button
339 + key={accent}
340 + title={accent}
341 + onClick={() => updateSettings({ accent })}
342 + style={{
343 + width: 28,
344 + height: 28,
345 + borderRadius: '50%',
346 + background: color,
347 + border:
348 + settings.accent === accent
349 + ? '2px solid var(--z-text-primary)'
350 + : '2px solid transparent',
351 + }}
352 + />
353 + ))}
354 + </div>
355 + </div>
356 + <div className="settings-section">
357 + <h3>Chat font size</h3>
358 + <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
359 + <input
360 + type="range"
361 + min={12}
362 + max={18}
363 + step={0.5}
364 + value={settings.chatFontSize}
365 + style={{ flex: 1, accentColor: 'var(--z-accent)' }}
366 + onChange={(e) => updateSettings({ chatFontSize: Number(e.target.value) })}
367 + />
368 + <span style={{ fontSize: 12 }}>{settings.chatFontSize.toFixed(1)} px</span>
369 + </div>
370 + <div
371 + style={{
372 + marginTop: 8,
373 + padding: 12,
374 + background: 'var(--z-surface)',
375 + border: 'var(--z-hairline) solid var(--z-border)',
376 + borderRadius: 10,
377 + fontSize: settings.chatFontSize,
378 + lineHeight: 1.45,
379 + }}
380 + >
381 + The quick brown fox jumps over the lazy dog.
382 + </div>
383 + </div>
384 + <div className="settings-section">
385 + <h3>Density</h3>
386 + <div style={{ display: 'flex', gap: 8 }}>
387 + {(['comfortable', 'compact'] as const).map((density) => (
388 + <button
389 + key={density}
390 + className={`button${settings.density === density ? ' primary' : ''}`}
391 + onClick={() => updateSettings({ density })}
392 + >
393 + {density[0]?.toUpperCase() + density.slice(1)}
394 + </button>
395 + ))}
396 + </div>
397 + </div>
398 + </div>
399 + )
400 +}
401 +
402 +function DataTab() {
403 + const toast = useStore((s) => s.toast)
404 + const loadAll = useStore((s) => s.loadAll)
405 + const fileRef = useRef<HTMLInputElement>(null)
406 + const [includeKeys, setIncludeKeys] = useState(false)
407 + const [confirmClear, setConfirmClear] = useState<string | null>(null)
408 +
409 + const doImport = async (file: File) => {
410 + try {
411 + const parsed: unknown = JSON.parse(await file.text())
412 + if (!isExportedData(parsed)) throw new Error('Not a Zyquo Cloud Web export file')
413 + const result = await importAll(parsed)
414 + await loadAll()
415 + toast(
416 + `Imported ${result.conversations} conversations${result.keys > 0 ? ` and ${result.keys} keys` : ''}`,
417 + 'success'
418 + )
419 + } catch (err) {
420 + toast(err instanceof Error ? err.message : 'Import failed', 'error')
421 + }
422 + }
423 +
424 + return (
425 + <div>
426 + <div className="settings-section">
427 + <h3>Export</h3>
428 + <label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 12.5 }}>
429 + <input
430 + type="checkbox"
431 + checked={includeKeys}
432 + onChange={(e) => setIncludeKeys(e.target.checked)}
433 + />
434 + Include API keys
435 + </label>
436 + {includeKeys && (
437 + <p className="settings-note" style={{ color: 'var(--z-warning)' }}>
438 + ⚠ The exported file will contain your API keys in plain text. Store it safely.
439 + </p>
440 + )}
441 + <button
442 + className="button primary"
443 + style={{ marginTop: 8 }}
444 + onClick={() => void downloadBackup(includeKeys)}
445 + >
446 + Download backup (JSON)
447 + </button>
448 + </div>
449 + <div className="settings-section">
450 + <h3>Import</h3>
451 + <input
452 + ref={fileRef}
453 + type="file"
454 + accept="application/json"
455 + hidden
456 + onChange={(e) => {
457 + const file = e.target.files?.[0]
458 + if (file) void doImport(file)
459 + e.target.value = ''
460 + }}
461 + />
462 + <button className="button" onClick={() => fileRef.current?.click()}>
463 + Import backup file…
464 + </button>
465 + </div>
466 + <div className="settings-section">
467 + <h3>Clear data</h3>
468 + <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
469 + {(
470 + [
471 + ['conversations', 'Delete all conversations'],
472 + ['everything', 'Delete everything (keys, history, settings)'],
473 + ] as const
474 + ).map(([kind, label]) => (
475 + <button
476 + key={kind}
477 + className="button danger"
478 + onClick={async () => {
479 + if (confirmClear !== kind) {
480 + setConfirmClear(kind)
481 + setTimeout(() => setConfirmClear(null), 4000)
482 + return
483 + }
484 + if (kind === 'conversations') await clearConversations()
485 + else await clearEverything()
486 + await loadAll()
487 + setConfirmClear(null)
488 + toast('Cleared', 'success')
489 + if (kind === 'everything') window.location.reload()
490 + }}
491 + >
492 + {confirmClear === kind ? 'Click again to confirm' : label}
493 + </button>
494 + ))}
495 + </div>
496 + </div>
497 + </div>
498 + )
499 +}
500 +
501 +function AdvancedTab() {
502 + const settings = useStore((s) => s.settings)
503 + const updateSettings = useStore((s) => s.updateSettings)
504 + const toast = useStore((s) => s.toast)
505 + const [passphrase, setPassphrase] = useState('')
506 + const vaulted = isVaultEnabled()
507 +
508 + return (
509 + <div>
510 + <div className="settings-section">
511 + <h3>Default system prompt</h3>
512 + <textarea
513 + className="text-input"
514 + rows={4}
515 + placeholder="Applied to new conversations"
516 + value={settings.defaultSystemPrompt}
517 + onChange={(e) => updateSettings({ defaultSystemPrompt: e.target.value })}
518 + />
519 + </div>
520 + <div className="settings-section">
521 + <h3>Passphrase lock</h3>
522 + <p className="settings-note">
523 + Encrypts your stored keys with AES-GCM (key derived from your passphrase). Honest
524 + caveat: once unlocked, a compromised page can still read keys — this protects the data
525 + at rest, not a running session. Without a passphrase, keys are stored in plaintext.
526 + </p>
527 + <div style={{ display: 'flex', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
528 + <input
529 + className="text-input"
530 + type="password"
531 + placeholder="Passphrase"
532 + value={passphrase}
533 + onChange={(e) => setPassphrase(e.target.value)}
534 + />
535 + {!vaulted ? (
536 + <button
537 + className="button primary"
538 + disabled={passphrase.length < 4}
539 + onClick={async () => {
540 + await enableVault(passphrase)
541 + setPassphrase('')
542 + toast('Passphrase lock enabled', 'success')
543 + }}
544 + >
545 + Enable lock
546 + </button>
547 + ) : (
548 + <>
549 + <button
550 + className="button"
551 + disabled={passphrase.length < 4}
552 + onClick={async () => {
553 + try {
554 + await persistVault(passphrase)
555 + toast('Vault re-encrypted', 'success')
556 + } catch {
557 + toast('Wrong passphrase', 'error')
558 + }
559 + }}
560 + >
561 + Re-encrypt
562 + </button>
563 + <button
564 + className="button danger"
565 + disabled={passphrase.length < 4}
566 + onClick={async () => {
567 + try {
568 + await disableVault(passphrase)
569 + setPassphrase('')
570 + toast('Passphrase lock disabled', 'success')
571 + } catch {
572 + toast('Wrong passphrase', 'error')
573 + }
574 + }}
575 + >
576 + Disable lock
577 + </button>
578 + </>
579 + )}
580 + </div>
581 + {vaulted && <p className="settings-note" style={{ marginTop: 6 }}>Lock is enabled.</p>}
582 + </div>
583 + <div className="settings-section">
584 + <h3>Streaming</h3>
585 + <label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 12.5 }}>
586 + <input
587 + type="checkbox"
588 + checked={settings.streamingEnabled}
589 + onChange={(e) => updateSettings({ streamingEnabled: e.target.checked })}
590 + />
591 + Stream responses token-by-token
592 + </label>
593 + </div>
594 + </div>
595 + )
596 +}
added src/components/Sidebar.tsx +179 −0
@@ -0,0 +1,179 @@
1 +/*
2 + * Sidebar.tsx
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Conversation sidebar: wordmark, search (⌘F), New Chat, grouped rows
9 + * (Pinned / Today / Yesterday / Previous 7 Days / Older), footer with
10 + * Settings + usage summary. Collapses to a drawer on phones.
11 + */
12 +
13 +import { useMemo, useRef } from 'react'
14 +import { useStore } from '../state/store'
15 +import { matchesSearch } from '../storage/conversations'
16 +import type { Conversation } from '../types'
17 +import ZyquoGlyph from './ZyquoGlyph'
18 +import { IconGear, IconPin, IconPlus, IconSearch, IconTrash } from './icons'
19 +
20 +const GROUP_ORDER = ['Pinned', 'Today', 'Yesterday', 'Previous 7 Days', 'Older'] as const
21 +
22 +function groupFor(conversation: Conversation, now: Date): (typeof GROUP_ORDER)[number] {
23 + if (conversation.pinned) return 'Pinned'
24 + const updated = new Date(conversation.updatedAt)
25 + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
26 + if (updated.getTime() >= startOfToday) return 'Today'
27 + if (updated.getTime() >= startOfToday - 86_400_000) return 'Yesterday'
28 + if (updated.getTime() >= startOfToday - 7 * 86_400_000) return 'Previous 7 Days'
29 + return 'Older'
30 +}
31 +
32 +function relativeTime(timestamp: number): string {
33 + const delta = Date.now() - timestamp
34 + const minutes = Math.floor(delta / 60_000)
35 + if (minutes < 1) return 'now'
36 + if (minutes < 60) return `${minutes}m`
37 + const hours = Math.floor(minutes / 60)
38 + if (hours < 24) return `${hours}h`
39 + const days = Math.floor(hours / 24)
40 + if (days < 7) return `${days}d`
41 + return new Date(timestamp).toLocaleDateString()
42 +}
43 +
44 +export default function Sidebar() {
45 + const conversations = useStore((s) => s.conversations)
46 + const selectedID = useStore((s) => s.selectedID)
47 + const searchText = useStore((s) => s.searchText)
48 + const setSearchText = useStore((s) => s.setSearchText)
49 + const select = useStore((s) => s.select)
50 + const newConversation = useStore((s) => s.newConversation)
51 + const removeConversation = useStore((s) => s.removeConversation)
52 + const updateConversation = useStore((s) => s.updateConversation)
53 + const openSettings = useStore((s) => s.openSettings)
54 + const sidebarOpen = useStore((s) => s.sidebarOpen)
55 + const searchRef = useRef<HTMLInputElement>(null)
56 +
57 + const groups = useMemo(() => {
58 + const now = new Date()
59 + const filtered = conversations.filter((c) => matchesSearch(c, searchText))
60 + const byGroup = new Map<string, Conversation[]>()
61 + for (const conversation of filtered) {
62 + const group = groupFor(conversation, now)
63 + const list = byGroup.get(group) ?? []
64 + list.push(conversation)
65 + byGroup.set(group, list)
66 + }
67 + return GROUP_ORDER.filter((g) => byGroup.has(g)).map((g) => ({
68 + name: g,
69 + items: byGroup.get(g) ?? [],
70 + }))
71 + }, [conversations, searchText])
72 +
73 + const usage = useMemo(() => {
74 + let tokens = 0
75 + let cost = 0
76 + for (const conversation of conversations) {
77 + for (const message of conversation.messages) {
78 + tokens += (message.usage?.inputTokens ?? 0) + (message.usage?.outputTokens ?? 0)
79 + cost += message.estimatedCost ?? 0
80 + }
81 + }
82 + return { tokens, cost }
83 + }, [conversations])
84 +
85 + return (
86 + <aside className={`sidebar${sidebarOpen ? ' open' : ''}`}>
87 + <div className="sidebar-wordmark">
88 + <ZyquoGlyph size={20} />
89 + <span>Zyquo Cloud</span>
90 + </div>
91 + <div className="sidebar-search">
92 + <IconSearch />
93 + <input
94 + ref={searchRef}
95 + data-search-input
96 + placeholder="Search"
97 + value={searchText}
98 + onChange={(e) => setSearchText(e.target.value)}
99 + />
100 + </div>
101 + <button className="new-chat-button" onClick={() => newConversation()}>
102 + <IconPlus size={12} /> New Chat
103 + </button>
104 + <div className="conversation-list">
105 + {groups.map((group) => (
106 + <div key={group.name}>
107 + <div className="conversation-group-header">{group.name}</div>
108 + {group.items.map((conversation) => (
109 + <div
110 + key={conversation.id}
111 + role="button"
112 + tabIndex={0}
113 + className={`conversation-row${conversation.id === selectedID ? ' selected' : ''}`}
114 + onClick={() => select(conversation.id)}
115 + onKeyDown={(e) => e.key === 'Enter' && select(conversation.id)}
116 + >
117 + <div className="conversation-row-main">
118 + <div className="conversation-row-title">{conversation.title}</div>
119 + <div className="conversation-row-sub">
120 + {conversation.modelID} · {relativeTime(conversation.updatedAt)}
121 + </div>
122 + </div>
123 + {conversation.pinned && (
124 + <span className="pin-indicator">
125 + <IconPin size={9} />
126 + </span>
127 + )}
128 + <span className="conversation-row-actions">
129 + <button
130 + className="icon-button"
131 + title={conversation.pinned ? 'Unpin' : 'Pin'}
132 + onClick={(e) => {
133 + e.stopPropagation()
134 + updateConversation(conversation.id, { pinned: !conversation.pinned })
135 + }}
136 + >
137 + <IconPin size={10} />
138 + </button>
139 + <button
140 + className="icon-button danger"
141 + title="Delete"
142 + onClick={(e) => {
143 + e.stopPropagation()
144 + removeConversation(conversation.id)
145 + }}
146 + >
147 + <IconTrash size={10} />
148 + </button>
149 + </span>
150 + </div>
151 + ))}
152 + </div>
153 + ))}
154 + {groups.length === 0 && (
155 + <div className="panel-empty">
156 + {searchText ? 'No conversations match.' : 'No conversations yet.'}
157 + </div>
158 + )}
159 + </div>
160 + <div className="sidebar-footer">
161 + <button className="icon-button" title="Settings (⌘,)" onClick={() => openSettings()}>
162 + <IconGear />
163 + </button>
164 + {usage.tokens > 0 && (
165 + <span>
166 + {usage.tokens >= 1_000_000
167 + ? `${(usage.tokens / 1_000_000).toFixed(1)}M tok`
168 + : `${(usage.tokens / 1_000).toFixed(1)}K tok`}
169 + {usage.cost >= 0.01
170 + ? ` · ~$${usage.cost.toFixed(2)}`
171 + : usage.cost > 0
172 + ? ' · <$0.01'
173 + : ''}
174 + </span>
175 + )}
176 + </div>
177 + </aside>
178 + )
179 +}
added src/components/SuggestionGrid.tsx +68 −0
@@ -0,0 +1,68 @@
1 +/*
2 + * SuggestionGrid.tsx
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Empty-conversation state: centered Zyquo mark, greeting, model chip, and
9 + * the four suggestion cards (matches the native EmptyStateView).
10 + */
11 +
12 +import type { ReactNode } from 'react'
13 +import ZyquoGlyph from './ZyquoGlyph'
14 +import { IconCode, IconDoc, IconGlobe, IconLightbulb } from './icons'
15 +
16 +const SUGGESTIONS = [
17 + {
18 + icon: <IconLightbulb />,
19 + title: 'Explain something',
20 + prompt: 'Explain how transformer attention works, with a concrete example.',
21 + },
22 + {
23 + icon: <IconCode />,
24 + title: 'Review my code',
25 + prompt: 'Review this code for bugs and suggest improvements:\n\n',
26 + },
27 + {
28 + icon: <IconDoc />,
29 + title: 'Summarize a document',
30 + prompt: 'Summarize the key points of the following document:\n\n',
31 + },
32 + {
33 + icon: <IconGlobe />,
34 + title: 'Translate',
35 + prompt: 'Translate the following text to French, keeping the tone natural:\n\n',
36 + },
37 +]
38 +
39 +export default function SuggestionGrid({
40 + onPick,
41 + modelChip,
42 +}: {
43 + onPick: (prompt: string) => void
44 + modelChip?: ReactNode
45 +}) {
46 + return (
47 + <div className="empty-state">
48 + <ZyquoGlyph size={72} />
49 + <div style={{ textAlign: 'center' }}>
50 + <h1 className="empty-title">Welcome to Zyquo Cloud</h1>
51 + <p className="empty-subtitle">Your keys, every cloud model, one beautiful chat.</p>
52 + </div>
53 + {modelChip}
54 + <div className="suggestion-grid">
55 + {SUGGESTIONS.map((suggestion) => (
56 + <button
57 + key={suggestion.title}
58 + className="suggestion-card"
59 + onClick={() => onPick(suggestion.prompt)}
60 + >
61 + <span className="glyph">{suggestion.icon}</span>
62 + {suggestion.title}
63 + </button>
64 + ))}
65 + </div>
66 + </div>
67 + )
68 +}
added src/components/Toasts.tsx +42 −0
@@ -0,0 +1,42 @@
1 +/*
2 + * Toasts.tsx
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Toast stack: graceful human errors, success confirmations, and undo for
9 + * soft deletes.
10 + */
11 +
12 +import { useStore } from '../state/store'
13 +import { IconX } from './icons'
14 +
15 +export default function Toasts() {
16 + const toasts = useStore((s) => s.toasts)
17 + const dismissToast = useStore((s) => s.dismissToast)
18 + if (toasts.length === 0) return null
19 + return (
20 + <div className="toasts">
21 + {toasts.map((toast) => (
22 + <div key={toast.id} className={`toast ${toast.kind}`}>
23 + <span style={{ flex: 1 }}>{toast.text}</span>
24 + {toast.undo && (
25 + <button
26 + className="undo"
27 + onClick={() => {
28 + toast.undo?.()
29 + dismissToast(toast.id)
30 + }}
31 + >
32 + Undo
33 + </button>
34 + )}
35 + <button className="icon-button" onClick={() => dismissToast(toast.id)}>
36 + <IconX size={9} />
37 + </button>
38 + </div>
39 + ))}
40 + </div>
41 + )
42 +}
added src/components/ZyquoGlyph.tsx +40 −0
@@ -0,0 +1,40 @@
1 +/*
2 + * ZyquoGlyph.tsx
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * The Zyquo brand mark — the chunky Z with the block caret, reproduced 1:1
9 + * from the native CloudZGlyph (1024 grid, tight box 503×509). Single-tint
10 + * via currentColor; defaults to the accent token.
11 + */
12 +
13 +export default function ZyquoGlyph({
14 + size = 22,
15 + color = 'var(--z-accent)',
16 +}: {
17 + size?: number
18 + color?: string
19 +}) {
20 + return (
21 + <svg
22 + width={size}
23 + height={size * 1.0119}
24 + viewBox="0 0 503 509"
25 + xmlns="http://www.w3.org/2000/svg"
26 + style={{ color }}
27 + aria-label="Zyquo"
28 + >
29 + <g transform="translate(-267,-257)" fill="none" stroke="currentColor">
30 + <path
31 + d="M320 310H704L320 700H556"
32 + strokeWidth="106"
33 + strokeLinecap="round"
34 + strokeLinejoin="round"
35 + />
36 + <rect x="653" y="585" width="104" height="168" rx="24" fill="currentColor" stroke="none" />
37 + </g>
38 + </svg>
39 + )
40 +}
added src/components/icons.tsx +318 −0
@@ -0,0 +1,318 @@
1 +/*
2 + * icons.tsx
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Minimal inline SVG icon set (stroke-based, currentColor) — no icon
9 + * dependency (every dependency is key-theft surface). 16×16 grid.
10 + */
11 +
12 +import type { ReactNode } from 'react'
13 +
14 +function I({ children, size = 14 }: { children: ReactNode; size?: number }) {
15 + return (
16 + <svg
17 + width={size}
18 + height={size}
19 + viewBox="0 0 16 16"
20 + fill="none"
21 + stroke="currentColor"
22 + strokeWidth="1.5"
23 + strokeLinecap="round"
24 + strokeLinejoin="round"
25 + aria-hidden="true"
26 + >
27 + {children}
28 + </svg>
29 + )
30 +}
31 +
32 +export const IconSend = ({ size = 13 }: { size?: number }) => (
33 + <I size={size}>
34 + <path d="M8 13V3M3.5 7.5 8 3l4.5 4.5" />
35 + </I>
36 +)
37 +export const IconStop = ({ size = 11 }: { size?: number }) => (
38 + <svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
39 + <rect x="3" y="3" width="10" height="10" rx="2" />
40 + </svg>
41 +)
42 +export const IconPlus = ({ size = 13 }: { size?: number }) => (
43 + <I size={size}>
44 + <path d="M8 3v10M3 8h10" />
45 + </I>
46 +)
47 +export const IconSearch = ({ size = 12 }: { size?: number }) => (
48 + <I size={size}>
49 + <circle cx="7" cy="7" r="4.5" />
50 + <path d="m10.5 10.5 3 3" />
51 + </I>
52 +)
53 +export const IconGear = ({ size = 14 }: { size?: number }) => (
54 + <I size={size}>
55 + <circle cx="8" cy="8" r="2.2" />
56 + <path d="M8 1.8v2M8 12.2v2M1.8 8h2M12.2 8h2M3.6 3.6l1.4 1.4M11 11l1.4 1.4M12.4 3.6 11 5M5 11l-1.4 1.4" />
57 + </I>
58 +)
59 +export const IconTrash = ({ size = 12 }: { size?: number }) => (
60 + <I size={size}>
61 + <path d="M2.5 4.5h11M6.5 2.5h3M4 4.5 4.8 13.5h6.4L12 4.5M6.5 7v4M9.5 7v4" />
62 + </I>
63 +)
64 +export const IconPin = ({ size = 11 }: { size?: number }) => (
65 + <I size={size}>
66 + <path d="M9.5 2 14 6.5l-3 1-2 4-1.5-1.5L3 14.5 1.5 13l4.5-4.5L4.5 7l4-2z" />
67 + </I>
68 +)
69 +export const IconCopy = ({ size = 11 }: { size?: number }) => (
70 + <I size={size}>
71 + <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
72 + <path d="M10.5 3.5v-1h-8v8h1" />
73 + </I>
74 +)
75 +export const IconEdit = ({ size = 11 }: { size?: number }) => (
76 + <I size={size}>
77 + <path d="m2.5 13.5.6-3 8-8 2.4 2.4-8 8-3 .6zM9.5 4l2.5 2.5" />
78 + </I>
79 +)
80 +export const IconRefresh = ({ size = 11 }: { size?: number }) => (
81 + <I size={size}>
82 + <path d="M13 8a5 5 0 1 1-1.5-3.5M13 2v3h-3" />
83 + </I>
84 +)
85 +export const IconChevronDown = ({ size = 8 }: { size?: number }) => (
86 + <I size={size}>
87 + <path d="m3 5.5 5 5 5-5" strokeWidth="2.2" />
88 + </I>
89 +)
90 +export const IconChevronRight = ({ size = 8 }: { size?: number }) => (
91 + <I size={size}>
92 + <path d="m5.5 3 5 5-5 5" strokeWidth="2.2" />
93 + </I>
94 +)
95 +export const IconStar = ({ size = 11, filled = false }: { size?: number; filled?: boolean }) => (
96 + <svg
97 + width={size}
98 + height={size}
99 + viewBox="0 0 16 16"
100 + fill={filled ? 'currentColor' : 'none'}
101 + stroke="currentColor"
102 + strokeWidth="1.3"
103 + strokeLinejoin="round"
104 + aria-hidden="true"
105 + >
106 + <path d="M8 1.5 10 6l4.8.4-3.6 3.2 1 4.7L8 11.8l-4.2 2.5 1-4.7L1.2 6.4 6 6z" />
107 + </svg>
108 +)
109 +export const IconEye = ({ size = 10 }: { size?: number }) => (
110 + <I size={size}>
111 + <path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" />
112 + <circle cx="8" cy="8" r="2" />
113 + </I>
114 +)
115 +export const IconBrain = ({ size = 10 }: { size?: number }) => (
116 + <I size={size}>
117 + <path d="M8 2.5c-3 0-5 2-5 4.5 0 1.6.8 3 2 3.8V13c0 .8.7 1.5 1.5 1.5h3c.8 0 1.5-.7 1.5-1.5v-2.2c1.2-.8 2-2.2 2-3.8 0-2.5-2-4.5-5-4.5z" />
118 + <path d="M6 7.5h4M8 7.5v3" />
119 + </I>
120 +)
121 +export const IconWrench = ({ size = 10 }: { size?: number }) => (
122 + <I size={size}>
123 + <path d="M9.5 6.5 13 3a3.5 3.5 0 0 1-4.6 4.6L4 12a1.4 1.4 0 0 1-2-2l4.4-4.4A3.5 3.5 0 0 1 11 1l-2.5 2.5 1 2 2-1z" />
124 + </I>
125 +)
126 +export const IconPaperclip = ({ size = 14 }: { size?: number }) => (
127 + <I size={size}>
128 + <path d="m13 7.5-5 5a3.2 3.2 0 0 1-4.5-4.5l5.5-5.5a2.2 2.2 0 0 1 3 3L6.7 10.8a1.1 1.1 0 0 1-1.5-1.5L10 4.5" />
129 + </I>
130 +)
131 +export const IconX = ({ size = 11 }: { size?: number }) => (
132 + <I size={size}>
133 + <path d="m3.5 3.5 9 9M12.5 3.5l-9 9" />
134 + </I>
135 +)
136 +export const IconMenu = ({ size = 15 }: { size?: number }) => (
137 + <I size={size}>
138 + <path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11" />
139 + </I>
140 +)
141 +export const IconSliders = ({ size = 13 }: { size?: number }) => (
142 + <I size={size}>
143 + <path d="M2.5 5h11M2.5 11h11" />
144 + <circle cx="6" cy="5" r="1.6" fill="var(--z-surface)" />
145 + <circle cx="10" cy="11" r="1.6" fill="var(--z-surface)" />
146 + </I>
147 +)
148 +export const IconQuote = ({ size = 11 }: { size?: number }) => (
149 + <I size={size}>
150 + <path d="M3 10.5c0-4 1.5-6 4-7-1.5 2-1.5 3.5-1 4a2 2 0 1 1-3 3zM9 10.5c0-4 1.5-6 4-7-1.5 2-1.5 3.5-1 4a2 2 0 1 1-3 3z" />
151 + </I>
152 +)
153 +export const IconBranch = ({ size = 11 }: { size?: number }) => (
154 + <I size={size}>
155 + <circle cx="4" cy="4" r="1.8" />
156 + <circle cx="4" cy="12" r="1.8" />
157 + <circle cx="12" cy="7" r="1.8" />
158 + <path d="M4 5.8v4.4M4 8c0-2 6-1 8-1" />
159 + </I>
160 +)
161 +export const IconCheck = ({ size = 11 }: { size?: number }) => (
162 + <I size={size}>
163 + <path d="m2.5 8.5 3.5 3.5 7.5-8" />
164 + </I>
165 +)
166 +export const IconDownload = ({ size = 12 }: { size?: number }) => (
167 + <I size={size}>
168 + <path d="M8 2.5v8M4.5 7 8 10.5 11.5 7M2.5 13.5h11" />
169 + </I>
170 +)
171 +export const IconColumns = ({ size = 12 }: { size?: number }) => (
172 + <I size={size}>
173 + <rect x="2" y="2.5" width="12" height="11" rx="1.5" />
174 + <path d="M8 2.5v11" />
175 + </I>
176 +)
177 +export const IconInfo = ({ size = 12 }: { size?: number }) => (
178 + <I size={size}>
179 + <circle cx="8" cy="8" r="6" />
180 + <path d="M8 7.5V11M8 5.2v.1" />
181 + </I>
182 +)
183 +export const IconArrowDown = ({ size = 10 }: { size?: number }) => (
184 + <I size={size}>
185 + <path d="M8 3v10M3.5 8.5 8 13l4.5-4.5" />
186 + </I>
187 +)
188 +export const IconLightbulb = ({ size = 14 }: { size?: number }) => (
189 + <I size={size}>
190 + <path d="M8 1.5a4.5 4.5 0 0 1 2.5 8.2c-.5.4-.5 1-.5 1.8h-4c0-.8 0-1.4-.5-1.8A4.5 4.5 0 0 1 8 1.5zM6.5 13.5h3" />
191 + </I>
192 +)
193 +export const IconCode = ({ size = 14 }: { size?: number }) => (
194 + <I size={size}>
195 + <path d="m5 4.5-4 3.5 4 3.5M11 4.5l4 3.5-4 3.5" />
196 + </I>
197 +)
198 +export const IconDoc = ({ size = 14 }: { size?: number }) => (
199 + <I size={size}>
200 + <path d="M4 1.5h5.5L13 5v9.5H4zM9.5 1.5V5H13" />
201 + <path d="M6 8h5M6 10.5h5" />
202 + </I>
203 +)
204 +export const IconGlobe = ({ size = 14 }: { size?: number }) => (
205 + <I size={size}>
206 + <circle cx="8" cy="8" r="6" />
207 + <path d="M2 8h12M8 2c1.8 1.8 2.5 3.8 2.5 6S9.8 12.2 8 14c-1.8-1.8-2.5-3.8-2.5-6S6.2 3.8 8 2z" />
208 + </I>
209 +)
210 +export const IconMic = ({ size = 13 }: { size?: number }) => (
211 + <I size={size}>
212 + <rect x="6" y="1.5" width="4" height="8" rx="2" />
213 + <path d="M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2.5" />
214 + </I>
215 +)
216 +export const IconSpeaker = ({ size = 11 }: { size?: number }) => (
217 + <I size={size}>
218 + <path d="M2.5 6v4h2.5L9 13V3L5 6zM11 5.5a3.5 3.5 0 0 1 0 5" />
219 + </I>
220 +)
221 +export const IconCommand = ({ size = 13 }: { size?: number }) => (
222 + <I size={size}>
223 + <path d="M5.5 5.5h5v5h-5zM5.5 5.5H4a2 2 0 1 1 2-2v1.5M10.5 5.5H12a2 2 0 1 0-2-2v1.5M5.5 10.5H4a2 2 0 1 0 2 2v-1.5M10.5 10.5H12a2 2 0 1 1-2 2v-1.5" />
224 + </I>
225 +)
226 +
227 +/** Provider glyphs — simple distinctive marks per provider. */
228 +export function ProviderGlyph({ provider, size = 11 }: { provider: string; size?: number }) {
229 + switch (provider) {
230 + case 'openai':
231 + return (
232 + <I size={size}>
233 + <circle cx="8" cy="8" r="5.5" />
234 + <path d="M8 2.5v11M3.2 5.2l9.6 5.6M3.2 10.8l9.6-5.6" />
235 + </I>
236 + )
237 + case 'anthropic':
238 + return (
239 + <I size={size}>
240 + <path d="M3 13 8 3l5 10M5.2 9.5h5.6" />
241 + </I>
242 + )
243 + case 'xai':
244 + return (
245 + <I size={size}>
246 + <path d="m3 3 10 10M13 3 3 13" />
247 + </I>
248 + )
249 + case 'mistral':
250 + return (
251 + <I size={size}>
252 + <path d="M2.5 12V5M8 12V3M13.5 12V5M2.5 5c2 0 3-2 5.5-2s3.5 2 5.5 2" />
253 + </I>
254 + )
255 + case 'gemini':
256 + return (
257 + <I size={size}>
258 + <path d="M8 1.5C8.5 5 11 7.5 14.5 8 11 8.5 8.5 11 8 14.5 7.5 11 5 8.5 1.5 8 5 7.5 7.5 5 8 1.5z" />
259 + </I>
260 + )
261 + case 'qwen':
262 + return (
263 + <I size={size}>
264 + <circle cx="8" cy="8" r="5.5" />
265 + <path d="m10.5 10.5 3 3" />
266 + </I>
267 + )
268 + case 'deepseek':
269 + return (
270 + <I size={size}>
271 + <path d="M2 9c1.5-4 4-6.5 8-6.5 2 0 3.5.8 4 2-1 .3-2 1-2.5 2.5C10.5 10 7.5 13 4 13c-1 0-1.7-.3-2-1z" />
272 + </I>
273 + )
274 + case 'kimi':
275 + return (
276 + <I size={size}>
277 + <path d="M12.5 10.5A5.5 5.5 0 1 1 5.5 3.5a4.5 4.5 0 0 0 7 7z" />
278 + </I>
279 + )
280 + case 'perplexity':
281 + return (
282 + <I size={size}>
283 + <circle cx="8" cy="8" r="5.5" />
284 + <path d="M6 6.5c0-2.5 4-2.5 4 0 0 1.5-2 1.5-2 3M8 11.5v.1" />
285 + </I>
286 + )
287 + case 'together':
288 + return (
289 + <I size={size}>
290 + <circle cx="5.5" cy="5.5" r="2.5" />
291 + <circle cx="10.5" cy="10.5" r="2.5" />
292 + <circle cx="10.5" cy="5.5" r="2.5" />
293 + <circle cx="5.5" cy="10.5" r="2.5" />
294 + </I>
295 + )
296 + case 'deepinfra':
297 + return (
298 + <I size={size}>
299 + <path d="M8 1.5 14 5v6l-6 3.5L2 11V5z" />
300 + <path d="M8 8v6.5M8 8 2 5M8 8l6-3" />
301 + </I>
302 + )
303 + case 'cerebras':
304 + return (
305 + <I size={size}>
306 + <circle cx="8" cy="8" r="5.5" />
307 + <path d="M8 4.5A3.5 3.5 0 1 0 11.5 8" />
308 + </I>
309 + )
310 + default:
311 + return (
312 + <I size={size}>
313 + <circle cx="8" cy="8" r="5.5" />
314 + <path d="M8 5v3.5" />
315 + </I>
316 + )
317 + }
318 +}
added src/design/components.css +1373 −0
@@ -0,0 +1,1373 @@
1 +/*
2 + * components.css
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Component styles — every value comes from tokens.css. Layout mirrors the
9 + * native app (sidebar 260 + centered 760 transcript column), responsive:
10 + * the sidebar becomes a drawer under 768px.
11 + */
12 +
13 +/* ---------- Shell ---------- */
14 +
15 +.shell {
16 + display: flex;
17 + height: 100%;
18 + background: var(--z-background);
19 +}
20 +
21 +.chat-area {
22 + flex: 1;
23 + display: flex;
24 + flex-direction: column;
25 + min-width: 0;
26 + height: 100%;
27 +}
28 +
29 +/* ---------- Sidebar ---------- */
30 +
31 +.sidebar {
32 + width: var(--z-sidebar-width);
33 + min-width: var(--z-sidebar-width);
34 + display: flex;
35 + flex-direction: column;
36 + border-right: var(--z-hairline) solid var(--z-border);
37 + background: var(--z-background);
38 + height: 100%;
39 +}
40 +
41 +.sidebar-wordmark {
42 + display: flex;
43 + align-items: center;
44 + gap: var(--z-space-xs);
45 + padding: 12px var(--z-space-md) var(--z-space-xs);
46 + font-size: 14px;
47 + font-weight: 500;
48 + user-select: none;
49 +}
50 +
51 +.sidebar-search {
52 + display: flex;
53 + align-items: center;
54 + gap: 6px;
55 + margin: 0 var(--z-space-sm) var(--z-space-xs);
56 + padding: 5px var(--z-space-xs);
57 + border-radius: var(--z-radius-small);
58 + background: color-mix(in srgb, var(--z-surface-secondary) 70%, transparent);
59 + color: var(--z-text-tertiary);
60 +}
61 +.sidebar-search input {
62 + flex: 1;
63 + border: none;
64 + outline: none;
65 + background: transparent;
66 + font-size: 12.5px;
67 + color: var(--z-text-primary);
68 + min-width: 0;
69 +}
70 +
71 +.new-chat-button {
72 + display: flex;
73 + align-items: center;
74 + justify-content: center;
75 + gap: 6px;
76 + margin: 0 var(--z-space-sm) var(--z-space-xs);
77 + padding: 7px var(--z-space-sm);
78 + border-radius: var(--z-radius-small);
79 + background: var(--z-accent);
80 + color: var(--z-on-accent);
81 + font-size: 13px;
82 + font-weight: 500;
83 + transition: transform var(--z-motion-hover);
84 +}
85 +.new-chat-button:active {
86 + transform: scale(var(--z-motion-pressed-scale));
87 +}
88 +
89 +.conversation-list {
90 + flex: 1;
91 + overflow-y: auto;
92 + padding: 0 var(--z-space-xs);
93 +}
94 +
95 +.conversation-group-header {
96 + font-size: var(--z-font-size-caption);
97 + color: var(--z-text-tertiary);
98 + padding: 12px var(--z-space-xs) 2px;
99 + user-select: none;
100 +}
101 +
102 +.conversation-row {
103 + display: flex;
104 + align-items: center;
105 + gap: 6px;
106 + width: 100%;
107 + text-align: left;
108 + padding: 5px var(--z-space-xs);
109 + border-radius: var(--z-radius-small);
110 + margin-bottom: 2px;
111 + transition: background var(--z-motion-hover);
112 + position: relative;
113 +}
114 +.conversation-row:hover {
115 + background: color-mix(in srgb, var(--z-surface-secondary) 60%, transparent);
116 +}
117 +.conversation-row.selected {
118 + background: var(--z-accent-subtle);
119 +}
120 +.conversation-row-main {
121 + flex: 1;
122 + min-width: 0;
123 +}
124 +.conversation-row-title {
125 + font-size: 13px;
126 + color: var(--z-text-primary);
127 + white-space: nowrap;
128 + overflow: hidden;
129 + text-overflow: ellipsis;
130 +}
131 +.conversation-row-sub {
132 + font-size: var(--z-font-size-caption);
133 + color: var(--z-text-tertiary);
134 + white-space: nowrap;
135 + overflow: hidden;
136 + text-overflow: ellipsis;
137 + margin-top: 1px;
138 +}
139 +.conversation-row-actions {
140 + display: none;
141 + gap: 4px;
142 + color: var(--z-text-tertiary);
143 +}
144 +.conversation-row:hover .conversation-row-actions {
145 + display: flex;
146 +}
147 +.conversation-row .pin-indicator {
148 + color: var(--z-text-tertiary);
149 + font-size: 9px;
150 +}
151 +.conversation-row:hover .pin-indicator {
152 + display: none;
153 +}
154 +
155 +.sidebar-footer {
156 + display: flex;
157 + align-items: center;
158 + justify-content: space-between;
159 + padding: var(--z-space-xs) var(--z-space-md);
160 + border-top: var(--z-hairline) solid var(--z-border);
161 + color: var(--z-text-tertiary);
162 + font-size: var(--z-font-size-caption);
163 +}
164 +
165 +.icon-button {
166 + display: inline-flex;
167 + align-items: center;
168 + justify-content: center;
169 + width: 26px;
170 + height: 26px;
171 + border-radius: var(--z-radius-small);
172 + color: var(--z-text-secondary);
173 + transition: background var(--z-motion-hover);
174 +}
175 +.icon-button:hover {
176 + background: var(--z-surface-secondary);
177 +}
178 +.icon-button.danger:hover {
179 + color: var(--z-danger);
180 +}
181 +
182 +/* ---------- Chat header ---------- */
183 +
184 +.chat-header {
185 + height: var(--z-chat-header-height);
186 + min-height: var(--z-chat-header-height);
187 + display: grid;
188 + grid-template-columns: 1fr auto 1fr;
189 + align-items: center;
190 + padding: 0 var(--z-space-md);
191 + border-bottom: var(--z-hairline) solid var(--z-border);
192 + gap: var(--z-space-xs);
193 +}
194 +.chat-header-left {
195 + display: flex;
196 + align-items: center;
197 + gap: var(--z-space-xs);
198 + min-width: 0;
199 +}
200 +.chat-header-right {
201 + display: flex;
202 + align-items: center;
203 + gap: 2px;
204 + justify-content: flex-end;
205 +}
206 +.chat-title {
207 + font-size: 13px;
208 + font-weight: 500;
209 + max-width: 220px;
210 + white-space: nowrap;
211 + overflow: hidden;
212 + text-overflow: ellipsis;
213 + cursor: default;
214 +}
215 +.chat-title-input {
216 + font-size: 13px;
217 + font-weight: 500;
218 + border: none;
219 + outline: none;
220 + background: var(--z-surface-secondary);
221 + border-radius: var(--z-radius-small);
222 + padding: 2px 6px;
223 + max-width: 220px;
224 +}
225 +
226 +/* ---------- Model chip ---------- */
227 +
228 +.model-chip {
229 + display: inline-flex;
230 + align-items: center;
231 + gap: 6px;
232 + padding: 4px var(--z-space-xs);
233 + border-radius: var(--z-radius-small);
234 + background: var(--z-surface-secondary);
235 + font-size: 12.5px;
236 + font-weight: 500;
237 + color: var(--z-text-primary);
238 + transition: transform var(--z-motion-hover);
239 + white-space: nowrap;
240 + max-width: 60vw;
241 + overflow: hidden;
242 + text-overflow: ellipsis;
243 +}
244 +.model-chip:active {
245 + transform: scale(var(--z-motion-pressed-scale));
246 +}
247 +.model-chip .glyph {
248 + color: var(--z-accent);
249 + display: inline-flex;
250 +}
251 +.model-chip .chevron {
252 + color: var(--z-text-tertiary);
253 + font-size: 8px;
254 +}
255 +
256 +/* ---------- Model menu (command-palette style) ---------- */
257 +
258 +.overlay {
259 + position: fixed;
260 + inset: 0;
261 + background: rgba(10, 12, 20, 0.25);
262 + display: flex;
263 + align-items: flex-start;
264 + justify-content: center;
265 + padding: 8vh var(--z-space-md) var(--z-space-md);
266 + z-index: 100;
267 + animation: overlay-in 120ms ease-out;
268 +}
269 +@keyframes overlay-in {
270 + from {
271 + opacity: 0;
272 + }
273 + to {
274 + opacity: 1;
275 + }
276 +}
277 +
278 +.panel {
279 + background: var(--z-surface);
280 + border: var(--z-hairline) solid var(--z-border);
281 + border-radius: var(--z-radius-large);
282 + box-shadow: var(--z-shadow-panel);
283 + width: 620px;
284 + max-width: 100%;
285 + max-height: 72vh;
286 + display: flex;
287 + flex-direction: column;
288 + overflow: hidden;
289 + animation: panel-in 150ms ease-out;
290 +}
291 +@keyframes panel-in {
292 + from {
293 + opacity: 0;
294 + transform: translateY(6px) scale(0.99);
295 + }
296 + to {
297 + opacity: 1;
298 + transform: none;
299 + }
300 +}
301 +
302 +.panel-search {
303 + display: flex;
304 + align-items: center;
305 + gap: var(--z-space-xs);
306 + padding: var(--z-space-sm) var(--z-space-md);
307 + border-bottom: var(--z-hairline) solid var(--z-border);
308 + color: var(--z-text-tertiary);
309 + flex-shrink: 0;
310 +}
311 +.panel-search input {
312 + flex: 1;
313 + border: none;
314 + outline: none;
315 + background: transparent;
316 + font-size: 15px;
317 + color: var(--z-text-primary);
318 +}
319 +
320 +.panel-filters {
321 + display: flex;
322 + gap: 6px;
323 + padding: var(--z-space-xs) var(--z-space-md);
324 + border-bottom: var(--z-hairline) solid var(--z-border);
325 + overflow-x: auto;
326 + flex-wrap: nowrap;
327 + flex-shrink: 0;
328 +}
329 +.panel-footer {
330 + flex-shrink: 0;
331 +}
332 +.filter-chip {
333 + font-size: var(--z-font-size-caption);
334 + padding: 3px 10px;
335 + border-radius: 999px;
336 + background: var(--z-surface-secondary);
337 + color: var(--z-text-secondary);
338 + white-space: nowrap;
339 + transition: background var(--z-motion-hover);
340 +}
341 +.filter-chip.active {
342 + background: var(--z-accent);
343 + color: var(--z-on-accent);
344 +}
345 +
346 +.panel-list {
347 + flex: 1;
348 + overflow-y: auto;
349 + padding: var(--z-space-xs);
350 +}
351 +
352 +.panel-section-header {
353 + font-size: var(--z-font-size-caption);
354 + color: var(--z-text-tertiary);
355 + padding: 10px var(--z-space-xs) 2px;
356 + display: flex;
357 + align-items: center;
358 + gap: 6px;
359 + user-select: none;
360 +}
361 +
362 +.model-row {
363 + display: flex;
364 + align-items: center;
365 + gap: var(--z-space-xs);
366 + width: 100%;
367 + text-align: left;
368 + padding: 5px var(--z-space-xs);
369 + border-radius: var(--z-radius-small);
370 + transition: background var(--z-motion-hover);
371 +}
372 +.model-row:hover,
373 +.model-row.highlighted {
374 + background: var(--z-surface-secondary);
375 +}
376 +.model-row.current {
377 + background: var(--z-accent-subtle);
378 +}
379 +.model-row .glyph {
380 + color: var(--z-accent);
381 + width: 16px;
382 + display: inline-flex;
383 + justify-content: center;
384 +}
385 +.model-row-name {
386 + font-size: 12.5px;
387 + color: var(--z-text-primary);
388 + white-space: nowrap;
389 + overflow: hidden;
390 + text-overflow: ellipsis;
391 +}
392 +.model-row-provider {
393 + font-size: var(--z-font-size-caption);
394 + color: var(--z-text-tertiary);
395 +}
396 +.model-row-badges {
397 + display: flex;
398 + align-items: center;
399 + gap: 6px;
400 + margin-left: auto;
401 + color: var(--z-text-tertiary);
402 + font-size: var(--z-font-size-caption);
403 + flex-shrink: 0;
404 +}
405 +.capability-badge {
406 + font-size: 10px;
407 +}
408 +.model-row .star {
409 + color: var(--z-text-tertiary);
410 + font-size: 11px;
411 +}
412 +.model-row .star.active {
413 + color: var(--z-warning);
414 +}
415 +.badge {
416 + font-size: 10px;
417 + padding: 1px 6px;
418 + border-radius: 999px;
419 + background: var(--z-surface-secondary);
420 + color: var(--z-text-secondary);
421 + white-space: nowrap;
422 +}
423 +.badge.accent {
424 + background: var(--z-accent-subtle);
425 + color: var(--z-accent);
426 +}
427 +.badge.warn {
428 + background: color-mix(in srgb, var(--z-warning) 12%, transparent);
429 + color: var(--z-warning);
430 +}
431 +
432 +.panel-empty {
433 + padding: var(--z-space-xl);
434 + text-align: center;
435 + color: var(--z-text-tertiary);
436 + font-size: 12.5px;
437 +}
438 +
439 +.panel-footer {
440 + display: flex;
441 + gap: var(--z-space-md);
442 + padding: var(--z-space-xs) var(--z-space-md);
443 + border-top: var(--z-hairline) solid var(--z-border);
444 + color: var(--z-text-tertiary);
445 + font-size: var(--z-font-size-caption);
446 +}
447 +.panel-footer kbd {
448 + background: var(--z-surface-secondary);
449 + border-radius: 4px;
450 + padding: 0 5px;
451 + font-size: 10px;
452 +}
453 +
454 +/* ---------- Transcript ---------- */
455 +
456 +.transcript {
457 + flex: 1;
458 + overflow-y: auto;
459 + overflow-x: hidden;
460 + position: relative;
461 +}
462 +.transcript-column {
463 + max-width: var(--z-max-message-column);
464 + margin: 0 auto;
465 + padding: var(--z-space-md);
466 + display: flex;
467 + flex-direction: column;
468 + gap: var(--z-turn-rhythm);
469 +}
470 +.density-compact .transcript-column {
471 + gap: 10px;
472 +}
473 +
474 +.turn {
475 + display: flex;
476 + gap: var(--z-space-xs);
477 + animation: message-in var(--z-motion-send);
478 +}
479 +@keyframes message-in {
480 + from {
481 + opacity: 0;
482 + transform: translateY(4px);
483 + }
484 + to {
485 + opacity: 1;
486 + transform: none;
487 + }
488 +}
489 +.turn.user {
490 + justify-content: flex-end;
491 + padding-left: 60px;
492 +}
493 +.turn.assistant {
494 + padding-right: 60px;
495 +}
496 +
497 +.avatar {
498 + width: 26px;
499 + height: 26px;
500 + min-width: 26px;
501 + border-radius: 50%;
502 + background: var(--z-accent-subtle);
503 + color: var(--z-accent);
504 + display: flex;
505 + align-items: center;
506 + justify-content: center;
507 + margin-top: 2px;
508 +}
509 +
510 +.turn-body {
511 + display: flex;
512 + flex-direction: column;
513 + gap: 4px;
514 + min-width: 0;
515 + max-width: 100%;
516 +}
517 +
518 +.bubble {
519 + padding: 10px var(--z-space-sm);
520 + border-radius: var(--z-radius-medium);
521 + font-size: var(--z-chat-font-size);
522 + line-height: var(--z-line-height-body);
523 + overflow-wrap: break-word;
524 + min-width: 0;
525 +}
526 +.turn.user .bubble {
527 + background: var(--z-accent-subtle);
528 + white-space: pre-wrap;
529 +}
530 +.turn.assistant .bubble {
531 + background: var(--z-surface);
532 + border: var(--z-hairline) solid var(--z-border);
533 +}
534 +.bubble .error-note {
535 + display: flex;
536 + gap: 6px;
537 + align-items: flex-start;
538 + color: var(--z-danger);
539 + background: color-mix(in srgb, var(--z-danger) 8%, transparent);
540 + border-radius: var(--z-radius-small);
541 + padding: var(--z-space-xs);
542 + font-size: calc(var(--z-chat-font-size) - 1px);
543 + margin-top: 6px;
544 +}
545 +
546 +.meta-row {
547 + display: flex;
548 + align-items: center;
549 + gap: var(--z-space-xs);
550 + font-size: var(--z-font-size-caption);
551 + color: var(--z-text-tertiary);
552 + opacity: 0;
553 + transition: opacity var(--z-motion-hover);
554 + min-height: 18px;
555 + flex-wrap: wrap;
556 +}
557 +.turn:hover .meta-row,
558 +.meta-row.always {
559 + opacity: 1;
560 +}
561 +.turn.user .meta-row {
562 + justify-content: flex-end;
563 +}
564 +.meta-action {
565 + display: inline-flex;
566 + align-items: center;
567 + gap: 3px;
568 + color: var(--z-text-tertiary);
569 + border-radius: 4px;
570 + padding: 1px 4px;
571 + transition: background var(--z-motion-hover);
572 + font-size: var(--z-font-size-caption);
573 +}
574 +.meta-action:hover {
575 + background: var(--z-surface-secondary);
576 + color: var(--z-text-secondary);
577 +}
578 +
579 +.reasoning-section {
580 + margin-bottom: 6px;
581 +}
582 +.reasoning-toggle {
583 + display: inline-flex;
584 + align-items: center;
585 + gap: 5px;
586 + font-size: var(--z-font-size-caption);
587 + color: var(--z-text-secondary);
588 +}
589 +.reasoning-toggle .chev {
590 + display: inline-block;
591 + transition: transform 120ms ease;
592 + font-size: 8px;
593 +}
594 +.reasoning-toggle .chev.open {
595 + transform: rotate(90deg);
596 +}
597 +.reasoning-body {
598 + margin-top: 6px;
599 + padding: var(--z-space-xs);
600 + background: var(--z-surface-secondary);
601 + border-radius: var(--z-radius-small);
602 + font-family: var(--z-font-mono);
603 + font-size: calc(var(--z-chat-font-size) - 2px);
604 + color: var(--z-text-secondary);
605 + white-space: pre-wrap;
606 + max-height: 320px;
607 + overflow-y: auto;
608 +}
609 +
610 +.citations {
611 + display: flex;
612 + flex-wrap: wrap;
613 + gap: 4px;
614 + margin-top: 8px;
615 +}
616 +.citation-chip {
617 + display: inline-flex;
618 + align-items: center;
619 + gap: 5px;
620 + padding: 3px 8px;
621 + border-radius: 999px;
622 + background: var(--z-surface-secondary);
623 + font-size: var(--z-font-size-caption);
624 + color: var(--z-text-secondary);
625 + max-width: 220px;
626 +}
627 +.citation-chip:hover {
628 + text-decoration: none;
629 + background: var(--z-accent-subtle);
630 +}
631 +.citation-chip .num {
632 + width: 13px;
633 + height: 13px;
634 + min-width: 13px;
635 + border-radius: 50%;
636 + background: var(--z-accent);
637 + color: var(--z-on-accent);
638 + font-size: 9px;
639 + font-weight: 600;
640 + display: inline-flex;
641 + align-items: center;
642 + justify-content: center;
643 +}
644 +.citation-chip .title {
645 + white-space: nowrap;
646 + overflow: hidden;
647 + text-overflow: ellipsis;
648 +}
649 +
650 +.streaming-caret {
651 + display: inline-block;
652 + width: 7px;
653 + height: 15px;
654 + border-radius: 3px;
655 + background: var(--z-accent);
656 + vertical-align: text-bottom;
657 + margin-left: 2px;
658 + animation: caret-blink 1.1s ease-in-out infinite;
659 +}
660 +@keyframes caret-blink {
661 + 0%,
662 + 100% {
663 + opacity: 1;
664 + }
665 + 50% {
666 + opacity: 0.15;
667 + }
668 +}
669 +
670 +.jump-pill {
671 + position: absolute;
672 + bottom: var(--z-space-xs);
673 + left: 50%;
674 + transform: translateX(-50%);
675 + display: flex;
676 + align-items: center;
677 + gap: 5px;
678 + padding: 5px var(--z-space-sm);
679 + border-radius: 999px;
680 + background: var(--z-surface);
681 + border: var(--z-hairline) solid var(--z-border);
682 + box-shadow: var(--z-shadow-soft);
683 + font-size: var(--z-font-size-caption);
684 + color: var(--z-text-secondary);
685 + z-index: 5;
686 +}
687 +
688 +.variant-switch {
689 + display: inline-flex;
690 + align-items: center;
691 + gap: 4px;
692 + font-size: var(--z-font-size-caption);
693 + color: var(--z-text-tertiary);
694 +}
695 +
696 +/* ---------- Markdown ---------- */
697 +
698 +.markdown > *:first-child {
699 + margin-top: 0;
700 +}
701 +.markdown > *:last-child {
702 + margin-bottom: 0;
703 +}
704 +.markdown p,
705 +.markdown ul,
706 +.markdown ol,
707 +.markdown blockquote,
708 +.markdown table,
709 +.markdown pre {
710 + margin: 0 0 12px;
711 +}
712 +.markdown h1,
713 +.markdown h2,
714 +.markdown h3,
715 +.markdown h4,
716 +.markdown h5,
717 +.markdown h6 {
718 + font-weight: 600;
719 + margin: 16px 0 8px;
720 + line-height: 1.3;
721 +}
722 +.markdown h1 {
723 + font-size: calc(var(--z-chat-font-size) * 1.55);
724 +}
725 +.markdown h2 {
726 + font-size: calc(var(--z-chat-font-size) * 1.35);
727 +}
728 +.markdown h3 {
729 + font-size: calc(var(--z-chat-font-size) * 1.2);
730 +}
731 +.markdown h4,
732 +.markdown h5,
733 +.markdown h6 {
734 + font-size: calc(var(--z-chat-font-size) * 1.05);
735 +}
736 +.markdown blockquote {
737 + border-left: 3px solid var(--z-accent);
738 + border-radius: 2px;
739 + padding-left: 12px;
740 + color: var(--z-text-secondary);
741 +}
742 +.markdown ul,
743 +.markdown ol {
744 + padding-left: 24px;
745 +}
746 +.markdown li {
747 + margin-bottom: 3px;
748 +}
749 +.markdown code:not(pre code) {
750 + font-size: calc(var(--z-chat-font-size) - 1px);
751 + background: var(--z-surface-secondary);
752 + border-radius: 4px;
753 + padding: 1px 5px;
754 +}
755 +.markdown hr {
756 + border: none;
757 + border-top: var(--z-hairline) solid var(--z-border);
758 + margin: 12px 0;
759 +}
760 +.markdown table {
761 + border-collapse: collapse;
762 + border: var(--z-hairline) solid var(--z-border);
763 + border-radius: var(--z-radius-small);
764 + overflow: hidden;
765 + display: block;
766 + max-width: 100%;
767 + overflow-x: auto;
768 + width: fit-content;
769 +}
770 +.markdown th,
771 +.markdown td {
772 + padding: 8px 12px;
773 + border: var(--z-hairline) solid var(--z-border);
774 + font-size: calc(var(--z-chat-font-size) - 0.5px);
775 + text-align: left;
776 +}
777 +.markdown thead th {
778 + background: var(--z-surface-secondary);
779 + font-weight: 600;
780 +}
781 +.markdown tbody tr:nth-child(odd) {
782 + background: color-mix(in srgb, var(--z-surface-secondary) 40%, transparent);
783 +}
784 +.markdown img {
785 + max-width: 100%;
786 + border-radius: var(--z-radius-small);
787 +}
788 +.markdown input[type='checkbox'] {
789 + accent-color: var(--z-accent);
790 + margin-right: 4px;
791 +}
792 +
793 +/* Code blocks */
794 +.code-block {
795 + background: var(--z-surface-secondary);
796 + border-radius: var(--z-radius-medium);
797 + overflow: hidden;
798 +}
799 +.code-block-header {
800 + display: flex;
801 + align-items: center;
802 + justify-content: space-between;
803 + padding: 8px 12px 0;
804 + font-size: var(--z-font-size-caption);
805 + color: var(--z-text-secondary);
806 + text-transform: uppercase;
807 + letter-spacing: 0.04em;
808 +}
809 +.code-block-actions {
810 + display: flex;
811 + gap: 4px;
812 + opacity: 0;
813 + transition: opacity var(--z-motion-hover);
814 +}
815 +.code-block:hover .code-block-actions,
816 +.code-block-actions.confirming {
817 + opacity: 1;
818 +}
819 +.code-block-copy {
820 + font-size: var(--z-font-size-caption);
821 + color: var(--z-text-secondary);
822 + text-transform: none;
823 + letter-spacing: normal;
824 + padding: 1px 6px;
825 + border-radius: 4px;
826 +}
827 +.code-block-copy:hover {
828 + background: color-mix(in srgb, var(--z-border) 50%, transparent);
829 +}
830 +.code-block-copy.ok {
831 + color: var(--z-success);
832 +}
833 +.code-block pre {
834 + margin: 0;
835 + padding: 4px 12px 8px;
836 + overflow-x: auto;
837 + font-size: calc(var(--z-chat-font-size) - 1px);
838 + line-height: 1.5;
839 +}
840 +.code-block pre.wrap {
841 + white-space: pre-wrap;
842 + overflow-wrap: break-word;
843 +}
844 +.code-block code {
845 + background: none;
846 + padding: 0;
847 + font-size: inherit;
848 +}
849 +
850 +/* highlight.js theme mapped to Zyquo syntax tokens */
851 +.hljs-keyword,
852 +.hljs-selector-tag,
853 +.hljs-built_in,
854 +.hljs-tag {
855 + color: var(--z-code-keyword);
856 +}
857 +.hljs-string,
858 +.hljs-regexp,
859 +.hljs-addition {
860 + color: var(--z-code-string);
861 +}
862 +.hljs-comment,
863 +.hljs-quote,
864 +.hljs-deletion,
865 +.hljs-meta {
866 + color: var(--z-code-comment);
867 +}
868 +.hljs-number,
869 +.hljs-literal,
870 +.hljs-symbol {
871 + color: var(--z-code-number);
872 +}
873 +.hljs-type,
874 +.hljs-class .hljs-title,
875 +.hljs-title.class_ {
876 + color: var(--z-code-type);
877 +}
878 +.hljs-function .hljs-title,
879 +.hljs-title.function_ {
880 + color: var(--z-code-function);
881 +}
882 +.hljs-attr,
883 +.hljs-property,
884 +.hljs-variable {
885 + color: var(--z-code-property);
886 +}
887 +.hljs-attribute,
888 +.hljs-name {
889 + color: var(--z-code-attribute);
890 +}
891 +
892 +/* ---------- Input bar ---------- */
893 +
894 +.input-dock {
895 + padding: 0 var(--z-space-sm) var(--z-space-sm);
896 +}
897 +.input-bar {
898 + max-width: var(--z-max-message-column);
899 + margin: 0 auto;
900 + background: var(--z-surface);
901 + border: var(--z-hairline) solid var(--z-border);
902 + border-radius: var(--z-radius-large);
903 + box-shadow: var(--z-shadow-soft);
904 + padding: var(--z-space-sm);
905 + display: flex;
906 + flex-direction: column;
907 + gap: var(--z-space-xs);
908 +}
909 +.input-bar.drag-target {
910 + border: 1.5px dashed var(--z-accent);
911 +}
912 +.attachment-strip {
913 + display: flex;
914 + gap: var(--z-space-xs);
915 + overflow-x: auto;
916 +}
917 +.attachment-thumb {
918 + position: relative;
919 + width: var(--z-attachment-thumb);
920 + height: var(--z-attachment-thumb);
921 + min-width: var(--z-attachment-thumb);
922 + border-radius: var(--z-radius-small);
923 + overflow: hidden;
924 + background: var(--z-surface-secondary);
925 + display: flex;
926 + align-items: center;
927 + justify-content: center;
928 + font-size: 9px;
929 + color: var(--z-text-secondary);
930 + text-align: center;
931 + padding: 2px;
932 +}
933 +.attachment-thumb img {
934 + width: 100%;
935 + height: 100%;
936 + object-fit: cover;
937 +}
938 +.attachment-remove {
939 + position: absolute;
940 + top: 2px;
941 + right: 2px;
942 + width: 16px;
943 + height: 16px;
944 + border-radius: 50%;
945 + background: var(--z-text-primary);
946 + color: var(--z-background);
947 + font-size: 10px;
948 + line-height: 1;
949 + display: flex;
950 + align-items: center;
951 + justify-content: center;
952 +}
953 +.input-row {
954 + display: flex;
955 + align-items: flex-end;
956 + gap: var(--z-space-xs);
957 +}
958 +.input-editor {
959 + flex: 1;
960 + border: none;
961 + outline: none;
962 + background: transparent;
963 + resize: none;
964 + font-size: var(--z-chat-font-size);
965 + line-height: var(--z-line-height-body);
966 + max-height: 200px;
967 + min-height: 22px;
968 + padding: 2px 0;
969 + color: var(--z-text-primary);
970 +}
971 +.send-button {
972 + width: 28px;
973 + height: 28px;
974 + min-width: 28px;
975 + border-radius: 50%;
976 + background: var(--z-accent);
977 + color: var(--z-on-accent);
978 + display: flex;
979 + align-items: center;
980 + justify-content: center;
981 + transition: transform var(--z-motion-hover), background var(--z-motion-hover);
982 +}
983 +.send-button:disabled {
984 + background: var(--z-surface-secondary);
985 + color: var(--z-text-tertiary);
986 + cursor: default;
987 +}
988 +.send-button.stop {
989 + background: var(--z-danger);
990 +}
991 +.send-button:active:not(:disabled) {
992 + transform: scale(var(--z-motion-pressed-scale));
993 +}
994 +.input-hud {
995 + display: flex;
996 + align-items: center;
997 + gap: var(--z-space-xs);
998 + font-size: var(--z-font-size-caption);
999 + color: var(--z-text-tertiary);
1000 + flex-wrap: wrap;
1001 +}
1002 +.context-bar {
1003 + flex: 1;
1004 + min-width: 60px;
1005 + max-width: 160px;
1006 + height: 3px;
1007 + border-radius: 2px;
1008 + background: var(--z-surface-secondary);
1009 + overflow: hidden;
1010 +}
1011 +.context-bar-fill {
1012 + height: 100%;
1013 + background: var(--z-accent);
1014 + border-radius: 2px;
1015 + transition: width 300ms ease;
1016 +}
1017 +.context-bar-fill.warn {
1018 + background: var(--z-warning);
1019 +}
1020 +
1021 +/* ---------- Empty state ---------- */
1022 +
1023 +.empty-state {
1024 + flex: 1;
1025 + display: flex;
1026 + flex-direction: column;
1027 + align-items: center;
1028 + justify-content: center;
1029 + gap: var(--z-space-lg);
1030 + padding: var(--z-space-xl);
1031 + overflow-y: auto;
1032 +}
1033 +.empty-title {
1034 + font-size: var(--z-font-size-title);
1035 + font-weight: 600;
1036 + margin: 0;
1037 +}
1038 +.empty-subtitle {
1039 + color: var(--z-text-secondary);
1040 + margin: 0;
1041 +}
1042 +.suggestion-grid {
1043 + display: grid;
1044 + grid-template-columns: 1fr 1fr;
1045 + gap: var(--z-space-sm);
1046 + max-width: 460px;
1047 + width: 100%;
1048 +}
1049 +.suggestion-card {
1050 + display: flex;
1051 + align-items: center;
1052 + gap: var(--z-space-xs);
1053 + text-align: left;
1054 + padding: var(--z-space-sm);
1055 + background: var(--z-surface);
1056 + border: var(--z-hairline) solid var(--z-border);
1057 + border-radius: var(--z-radius-medium);
1058 + font-size: 13px;
1059 + transition: transform var(--z-motion-hover), background var(--z-motion-hover);
1060 +}
1061 +.suggestion-card:hover {
1062 + background: var(--z-surface-secondary);
1063 +}
1064 +.suggestion-card:active {
1065 + transform: scale(var(--z-motion-pressed-scale));
1066 +}
1067 +.suggestion-card .glyph {
1068 + color: var(--z-accent);
1069 + width: 20px;
1070 + display: inline-flex;
1071 + justify-content: center;
1072 +}
1073 +
1074 +/* ---------- First run ---------- */
1075 +
1076 +.first-run-note {
1077 + max-width: 560px;
1078 + margin: 0 auto;
1079 + background: var(--z-surface);
1080 + border: var(--z-hairline) solid var(--z-border);
1081 + border-radius: var(--z-radius-medium);
1082 + padding: var(--z-space-md);
1083 + font-size: 12.5px;
1084 + color: var(--z-text-secondary);
1085 + display: flex;
1086 + flex-direction: column;
1087 + gap: var(--z-space-xs);
1088 +}
1089 +.first-run-note strong {
1090 + color: var(--z-text-primary);
1091 +}
1092 +.first-run-actions {
1093 + display: flex;
1094 + gap: var(--z-space-xs);
1095 + justify-content: flex-end;
1096 +}
1097 +.button {
1098 + padding: 6px 14px;
1099 + border-radius: var(--z-radius-small);
1100 + font-size: 12.5px;
1101 + font-weight: 500;
1102 + background: var(--z-surface-secondary);
1103 + color: var(--z-text-primary);
1104 + transition: transform var(--z-motion-hover);
1105 +}
1106 +.button.primary {
1107 + background: var(--z-accent);
1108 + color: var(--z-on-accent);
1109 +}
1110 +.button.danger {
1111 + background: color-mix(in srgb, var(--z-danger) 10%, transparent);
1112 + color: var(--z-danger);
1113 +}
1114 +.button:active {
1115 + transform: scale(var(--z-motion-pressed-scale));
1116 +}
1117 +.button:disabled {
1118 + opacity: 0.5;
1119 + cursor: default;
1120 +}
1121 +
1122 +/* ---------- Settings ---------- */
1123 +
1124 +.settings-panel {
1125 + width: 760px;
1126 + max-width: 100%;
1127 + height: 560px;
1128 + max-height: 85vh;
1129 +}
1130 +.settings-body {
1131 + display: flex;
1132 + flex: 1;
1133 + min-height: 0;
1134 +}
1135 +.settings-tabs {
1136 + width: 168px;
1137 + min-width: 168px;
1138 + border-right: var(--z-hairline) solid var(--z-border);
1139 + padding: var(--z-space-xs);
1140 + display: flex;
1141 + flex-direction: column;
1142 + gap: 2px;
1143 +}
1144 +.settings-tab {
1145 + text-align: left;
1146 + padding: 6px 10px;
1147 + border-radius: var(--z-radius-small);
1148 + font-size: 13px;
1149 + color: var(--z-text-secondary);
1150 + transition: background var(--z-motion-hover);
1151 +}
1152 +.settings-tab:hover {
1153 + background: var(--z-surface-secondary);
1154 +}
1155 +.settings-tab.active {
1156 + background: var(--z-accent-subtle);
1157 + color: var(--z-text-primary);
1158 +}
1159 +.settings-content {
1160 + flex: 1;
1161 + overflow-y: auto;
1162 + padding: var(--z-space-md);
1163 + min-width: 0;
1164 +}
1165 +.settings-content h3 {
1166 + margin: 0 0 var(--z-space-sm);
1167 + font-size: 14px;
1168 +}
1169 +.settings-row {
1170 + display: flex;
1171 + align-items: center;
1172 + gap: var(--z-space-xs);
1173 + padding: var(--z-space-xs) 0;
1174 + border-bottom: var(--z-hairline) solid var(--z-border);
1175 + flex-wrap: wrap;
1176 +}
1177 +.settings-row:last-child {
1178 + border-bottom: none;
1179 +}
1180 +.provider-name {
1181 + display: flex;
1182 + align-items: center;
1183 + gap: 6px;
1184 + font-size: 13px;
1185 + font-weight: 500;
1186 + width: 140px;
1187 + min-width: 140px;
1188 +}
1189 +.status-dot {
1190 + width: 8px;
1191 + height: 8px;
1192 + border-radius: 50%;
1193 + background: var(--z-border);
1194 +}
1195 +.status-dot.ok {
1196 + background: var(--z-success);
1197 +}
1198 +.status-dot.fail {
1199 + background: var(--z-danger);
1200 +}
1201 +.status-dot.saved {
1202 + background: var(--z-text-tertiary);
1203 +}
1204 +.key-input {
1205 + flex: 1;
1206 + min-width: 160px;
1207 + font-family: var(--z-font-mono);
1208 + font-size: 11px;
1209 + padding: 5px 8px;
1210 + border: var(--z-hairline) solid var(--z-border);
1211 + border-radius: var(--z-radius-small);
1212 + background: var(--z-background);
1213 + outline: none;
1214 +}
1215 +.key-input:focus {
1216 + border-color: var(--z-accent);
1217 +}
1218 +.settings-sub {
1219 + font-size: var(--z-font-size-caption);
1220 + color: var(--z-text-tertiary);
1221 + width: 100%;
1222 +}
1223 +.settings-sub.ok {
1224 + color: var(--z-success);
1225 +}
1226 +.settings-sub.fail {
1227 + color: var(--z-danger);
1228 +}
1229 +.select,
1230 +.number-input,
1231 +.text-input {
1232 + padding: 5px 8px;
1233 + border: var(--z-hairline) solid var(--z-border);
1234 + border-radius: var(--z-radius-small);
1235 + background: var(--z-background);
1236 + font-size: 12.5px;
1237 + outline: none;
1238 +}
1239 +.text-input:focus,
1240 +.number-input:focus {
1241 + border-color: var(--z-accent);
1242 +}
1243 +.settings-section {
1244 + margin-bottom: var(--z-space-lg);
1245 +}
1246 +.settings-note {
1247 + font-size: var(--z-font-size-caption);
1248 + color: var(--z-text-tertiary);
1249 + line-height: 1.5;
1250 +}
1251 +textarea.text-input {
1252 + width: 100%;
1253 + resize: vertical;
1254 + font-family: inherit;
1255 +}
1256 +
1257 +/* ---------- Toasts ---------- */
1258 +
1259 +.toasts {
1260 + position: fixed;
1261 + bottom: var(--z-space-md);
1262 + right: var(--z-space-md);
1263 + display: flex;
1264 + flex-direction: column;
1265 + gap: var(--z-space-xs);
1266 + z-index: 200;
1267 + max-width: min(380px, calc(100vw - 32px));
1268 +}
1269 +.toast {
1270 + display: flex;
1271 + align-items: center;
1272 + gap: var(--z-space-xs);
1273 + background: var(--z-surface);
1274 + border: var(--z-hairline) solid var(--z-border);
1275 + border-left: 3px solid var(--z-accent);
1276 + border-radius: var(--z-radius-medium);
1277 + box-shadow: var(--z-shadow-soft);
1278 + padding: var(--z-space-xs) var(--z-space-sm);
1279 + font-size: 12.5px;
1280 + animation: message-in var(--z-motion-send);
1281 +}
1282 +.toast.error {
1283 + border-left-color: var(--z-danger);
1284 +}
1285 +.toast.success {
1286 + border-left-color: var(--z-success);
1287 +}
1288 +.toast .undo {
1289 + color: var(--z-accent);
1290 + font-weight: 500;
1291 +}
1292 +
1293 +/* ---------- Drawer / responsive ---------- */
1294 +
1295 +.drawer-scrim {
1296 + display: none;
1297 +}
1298 +.menu-button {
1299 + display: none;
1300 +}
1301 +
1302 +@media (max-width: 768px) {
1303 + .sidebar {
1304 + position: fixed;
1305 + left: 0;
1306 + top: 0;
1307 + bottom: 0;
1308 + z-index: 60;
1309 + transform: translateX(-100%);
1310 + transition: transform 200ms ease;
1311 + box-shadow: none;
1312 + width: min(300px, 85vw);
1313 + min-width: 0;
1314 + }
1315 + .sidebar.open {
1316 + transform: none;
1317 + box-shadow: var(--z-shadow-panel);
1318 + }
1319 + .drawer-scrim {
1320 + display: block;
1321 + position: fixed;
1322 + inset: 0;
1323 + background: rgba(10, 12, 20, 0.3);
1324 + z-index: 50;
1325 + }
1326 + .menu-button {
1327 + display: inline-flex;
1328 + }
1329 + .turn.user {
1330 + padding-left: 24px;
1331 + }
1332 + .turn.assistant {
1333 + padding-right: 12px;
1334 + }
1335 + .avatar {
1336 + display: none;
1337 + }
1338 + .overlay {
1339 + padding: 4vh var(--z-space-xs) var(--z-space-xs);
1340 + }
1341 + .settings-body {
1342 + flex-direction: column;
1343 + }
1344 + .settings-tabs {
1345 + width: 100%;
1346 + min-width: 0;
1347 + flex-direction: row;
1348 + overflow-x: auto;
1349 + border-right: none;
1350 + border-bottom: var(--z-hairline) solid var(--z-border);
1351 + }
1352 + .settings-panel {
1353 + height: 90vh;
1354 + }
1355 + .icon-button,
1356 + .meta-action,
1357 + .conversation-row {
1358 + min-height: 32px;
1359 + }
1360 + .chat-title {
1361 + max-width: 120px;
1362 + }
1363 +}
1364 +
1365 +/* Focus mode hides the sidebar on desktop */
1366 +.focus-mode .sidebar {
1367 + display: none;
1368 +}
1369 +@media (max-width: 768px) {
1370 + .focus-mode .sidebar {
1371 + display: flex;
1372 + }
1373 +}
added src/features/catalogHelpers.ts +38 −0
@@ -0,0 +1,38 @@
1 +/*
2 + * catalogHelpers.ts
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Catalog conveniences shared by the store and UI: re-exports of the ported
9 + * catalog queries plus cost/estimation helpers.
10 + */
11 +
12 +import { pricingCost, type AIModel, type TokenUsage } from '../types'
13 +
14 +export { CATALOG, cheapestModel, defaultModel, findModel, modelsFor, rankModel } from '../providers/catalog'
15 +
16 +/** Estimated USD cost of a usage record for a model (0 when unpriced). */
17 +export function pricedCost(model: AIModel, usage: TokenUsage): number {
18 + if (!model.pricing) return 0
19 + return pricingCost(model.pricing, usage.inputTokens, usage.outputTokens)
20 +}
21 +
22 +/** Rough token estimate for context-window accounting (≈4 chars/token). */
23 +export function estimateTokens(text: string): number {
24 + return Math.ceil(text.length / 4)
25 +}
26 +
27 +/** "$1.25 / $10" per-MTok badge, or "—" when unpriced. */
28 +export function priceBadge(model: AIModel): string {
29 + if (!model.pricing) return '—'
30 + const fmt = (v: number) => (v >= 1 ? `$${v.toFixed(2).replace(/\.?0+$/, '')}` : `$${v}`)
31 + return `${fmt(model.pricing.inputPerMTok)} / ${fmt(model.pricing.outputPerMTok)}`
32 +}
33 +
34 +/** "$0.0042" style cost display. */
35 +export function costLabel(cost: number): string {
36 + if (cost <= 0) return ''
37 + return `~$${cost.toFixed(4)}`
38 +}
added src/features/exporters.ts +60 −0
@@ -0,0 +1,60 @@
1 +/*
2 + * exporters.ts
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Single-conversation exports (Markdown / JSON) and the all-data backup
9 + * download — client-side file downloads, no server.
10 + */
11 +
12 +import { exportAll } from '../storage/backup'
13 +import type { Conversation } from '../types'
14 +
15 +function download(filename: string, content: string, type: string): void {
16 + const blob = new Blob([content], { type })
17 + const url = URL.createObjectURL(blob)
18 + const a = document.createElement('a')
19 + a.href = url
20 + a.download = filename
21 + a.click()
22 + URL.revokeObjectURL(url)
23 +}
24 +
25 +function safeName(title: string): string {
26 + return title.replace(/[/\\:*?"<>|]/g, '-').slice(0, 80)
27 +}
28 +
29 +export function exportConversationMarkdown(conversation: Conversation): void {
30 + const lines: string[] = [`# ${conversation.title}`, '']
31 + lines.push(`*Model: ${conversation.modelID} (${conversation.provider})*`, '')
32 + if (conversation.systemPrompt) {
33 + lines.push(`> **System:** ${conversation.systemPrompt}`, '')
34 + }
35 + for (const message of conversation.messages) {
36 + const label = message.role === 'user' ? '**You**' : `**Assistant** (${message.modelID ?? ''})`
37 + lines.push(`${label}:`, '', message.text, '')
38 + if (message.citations && message.citations.length > 0) {
39 + lines.push(
40 + ...message.citations.map((c) => `[${c.index}]: ${c.url}${c.title ? ` "${c.title}"` : ''}`),
41 + ''
42 + )
43 + }
44 + }
45 + download(`${safeName(conversation.title)}.md`, lines.join('\n'), 'text/markdown')
46 +}
47 +
48 +export function exportConversationJSON(conversation: Conversation): void {
49 + download(
50 + `${safeName(conversation.title)}.json`,
51 + JSON.stringify(conversation, null, 2),
52 + 'application/json'
53 + )
54 +}
55 +
56 +export async function downloadBackup(includeKeys: boolean): Promise<void> {
57 + const data = await exportAll(includeKeys)
58 + const stamp = new Date().toISOString().slice(0, 10)
59 + download(`zyquo-cloud-backup-${stamp}.json`, JSON.stringify(data, null, 2), 'application/json')
60 +}
added src/state/store.ts +619 −0
@@ -0,0 +1,619 @@
1 +/*
2 + * store.ts
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * The app store (Zustand): settings, keys status, conversations, streaming
9 + * state, and UI state. All persistence goes through storage/; all networking
10 + * through providers/. Conversation behaviors ported from the native
11 + * ConversationStore (send/stream/stop/regenerate/edit-resend/auto-title),
12 + * extended with web-only variants + branching.
13 + */
14 +
15 +import { create } from 'zustand'
16 +import { cheapestModel, defaultModel, findModel, pricedCost } from '../features/catalogHelpers'
17 +import { clientFor } from '../providers/registry'
18 +import { ProviderError } from '../providers/types'
19 +import {
20 + deleteConversation as dbDeleteConversation,
21 + loadConversations,
22 + saveConversation,
23 +} from '../storage/conversations'
24 +import { configuredProviders, getKey } from '../storage/keys'
25 +import { loadSettings, saveSettings } from '../storage/settings'
26 +import { isLocked } from '../storage/vault'
27 +import type {
28 + AIModel,
29 + Attachment,
30 + ChatParameters,
31 + Conversation,
32 + Message,
33 + MessageVariant,
34 + Provider,
35 + Settings,
36 +} from '../types'
37 +
38 +export function uid(): string {
39 + return crypto.randomUUID()
40 +}
41 +
42 +export interface Toast {
43 + id: string
44 + text: string
45 + kind: 'info' | 'error' | 'success'
46 + /** Optional undo action (soft deletes). */
47 + undo?: () => void
48 +}
49 +
50 +interface StreamingState {
51 + /** Message id currently being streamed into. */
52 + messageID: string
53 + abort: AbortController
54 + startedAt: number
55 + outputChars: number
56 +}
57 +
58 +export interface ZyquoState {
59 + // Settings
60 + settings: Settings
61 + updateSettings: (patch: Partial<Settings>) => void
62 +
63 + // Keys (presence only — values read from storage on demand)
64 + keyedProviders: Provider[]
65 + refreshKeyedProviders: () => void
66 + vaultLocked: boolean
67 + setVaultLocked: (locked: boolean) => void
68 +
69 + // Conversations
70 + conversations: Conversation[]
71 + selectedID: string | null
72 + searchText: string
73 + loaded: boolean
74 + loadAll: () => Promise<void>
75 + select: (id: string | null) => void
76 + setSearchText: (text: string) => void
77 + newConversation: (opts?: {
78 + model?: AIModel
79 + systemPrompt?: string
80 + personaID?: string
81 + parameters?: ChatParameters
82 + }) => Conversation
83 + updateConversation: (id: string, patch: Partial<Conversation>) => void
84 + removeConversation: (id: string) => void
85 + restoreConversation: (conversation: Conversation) => void
86 +
87 + // Chat actions
88 + send: (conversationID: string, text: string, attachments: Attachment[], overrideModel?: AIModel) => Promise<void>
89 + regenerate: (conversationID: string, messageID: string, overrideModel?: AIModel) => Promise<void>
90 + editAndResend: (conversationID: string, messageID: string, newText: string) => Promise<void>
91 + deleteMessage: (conversationID: string, messageID: string) => void
92 + stop: (conversationID: string) => void
93 + continueGeneration: (conversationID: string) => Promise<void>
94 + branchFrom: (conversationID: string, messageID: string) => Conversation | null
95 + setActiveVariant: (conversationID: string, messageID: string, index: number) => void
96 +
97 + // Streaming
98 + streaming: Record<string, StreamingState>
99 +
100 + // UI
101 + /** Prompt text to preload into the next-opened conversation's input. */
102 + draftSeed: string | null
103 + setDraftSeed: (seed: string | null) => void
104 + sidebarOpen: boolean
105 + setSidebarOpen: (open: boolean) => void
106 + modelMenuOpen: boolean
107 + setModelMenuOpen: (open: boolean) => void
108 + settingsOpen: boolean
109 + settingsTab: string
110 + openSettings: (tab?: string) => void
111 + closeSettings: () => void
112 + paletteOpen: boolean
113 + setPaletteOpen: (open: boolean) => void
114 + compareOpen: boolean
115 + setCompareOpen: (open: boolean) => void
116 + toasts: Toast[]
117 + toast: (text: string, kind?: Toast['kind'], undo?: () => void) => void
118 + dismissToast: (id: string) => void
119 +}
120 +
121 +function applyThemeToDocument(settings: Settings): void {
122 + const root = document.documentElement
123 + const dark =
124 + settings.theme === 'dark' ||
125 + (settings.theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches)
126 + if (dark) root.setAttribute('data-theme', 'dark')
127 + else root.removeAttribute('data-theme')
128 + if (settings.accent !== 'indigo') root.setAttribute('data-accent', settings.accent)
129 + else root.removeAttribute('data-accent')
130 + root.style.setProperty('--z-chat-font-size', `${settings.chatFontSize}px`)
131 +}
132 +
133 +export const useStore = create<ZyquoState>((set, get) => {
134 + const initialSettings = loadSettings()
135 + applyThemeToDocument(initialSettings)
136 +
137 + const persist = (conversation: Conversation) => {
138 + void saveConversation(conversation).catch(() => {
139 + get().toast('Failed to save conversation locally', 'error')
140 + })
141 + }
142 +
143 + const mutateConversation = (
144 + id: string,
145 + mutate: (c: Conversation) => Conversation,
146 + save = true
147 + ) => {
148 + set((state) => {
149 + const conversations = state.conversations.map((c) => {
150 + if (c.id !== id) return c
151 + const next = mutate(c)
152 + if (save) persist(next)
153 + return next
154 + })
155 + return { conversations }
156 + })
157 + }
158 +
159 + const modelFor = (conversation: Conversation): AIModel | undefined =>
160 + findModel(conversation.provider, conversation.modelID)
161 +
162 + /** Streams a reply into a fresh assistant message appended to the conversation. */
163 + const generateReply = async (conversationID: string, overrideModel?: AIModel) => {
164 + const state = get()
165 + const conversation = state.conversations.find((c) => c.id === conversationID)
166 + if (!conversation) return
167 + if (state.streaming[conversationID]) state.stop(conversationID)
168 +
169 + const model = overrideModel ?? modelFor(conversation)
170 + if (!model) {
171 + state.toast(`Model ${conversation.modelID} not found in catalog`, 'error')
172 + return
173 + }
174 + const apiKey = getKey(model.provider)
175 + if (!apiKey) {
176 + state.toast(ProviderError.missingAPIKey(model.provider).message, 'error')
177 + get().openSettings('providers')
178 + return
179 + }
180 + if (overrideModel) {
181 + mutateConversation(conversationID, (c) => ({
182 + ...c,
183 + modelID: overrideModel.id,
184 + provider: overrideModel.provider,
185 + }))
186 + }
187 +
188 + const placeholder: Message = {
189 + id: uid(),
190 + role: 'assistant',
191 + text: '',
192 + modelID: model.id,
193 + provider: model.provider,
194 + createdAt: Date.now(),
195 + }
196 + mutateConversation(
197 + conversationID,
198 + (c) => ({ ...c, messages: [...c.messages, placeholder], updatedAt: Date.now() }),
199 + false
200 + )
201 +
202 + const abort = new AbortController()
203 + set((s) => ({
204 + streaming: {
205 + ...s.streaming,
206 + [conversationID]: {
207 + messageID: placeholder.id,
208 + abort,
209 + startedAt: Date.now(),
210 + outputChars: 0,
211 + },
212 + },
213 + }))
214 +
215 + const source = get().conversations.find((c) => c.id === conversationID)
216 + if (!source) return
217 + // Request context: all messages except the placeholder and errored turns.
218 + const requestMessages = source.messages.filter(
219 + (m) => m.id !== placeholder.id && !m.errorText
220 + )
221 + const proxy = get().settings.proxyBaseURLs[model.provider]
222 +
223 + const patchMessage = (patch: (m: Message) => Message, save = false) => {
224 + mutateConversation(
225 + conversationID,
226 + (c) => ({
227 + ...c,
228 + messages: c.messages.map((m) => (m.id === placeholder.id ? patch(m) : m)),
229 + }),
230 + save
231 + )
232 + }
233 +
234 + try {
235 + const client = clientFor(model)
236 + const events = client.streamChat(
237 + {
238 + model,
239 + ...(source.systemPrompt ? { systemPrompt: source.systemPrompt } : {}),
240 + messages: requestMessages,
241 + parameters: source.parameters,
242 + stream: true,
243 + ...(proxy ? { baseURLOverride: proxy } : {}),
244 + ...(model.customBaseURL ? { baseURLOverride: model.customBaseURL } : {}),
245 + },
246 + apiKey,
247 + abort.signal
248 + )
249 + for await (const event of events) {
250 + switch (event.type) {
251 + case 'textDelta':
252 + set((s) => {
253 + const st = s.streaming[conversationID]
254 + return st
255 + ? {
256 + streaming: {
257 + ...s.streaming,
258 + [conversationID]: { ...st, outputChars: st.outputChars + event.text.length },
259 + },
260 + }
261 + : {}
262 + })
263 + patchMessage((m) => ({ ...m, text: m.text + event.text }))
264 + break
265 + case 'reasoningDelta':
266 + patchMessage((m) => ({ ...m, reasoning: (m.reasoning ?? '') + event.text }))
267 + break
268 + case 'citations':
269 + patchMessage((m) => ({ ...m, citations: event.citations }))
270 + break
271 + case 'usage':
272 + patchMessage((m) => ({
273 + ...m,
274 + usage: event.usage,
275 + ...(model.pricing
276 + ? { estimatedCost: pricedCost(model, event.usage) }
277 + : {}),
278 + }))
279 + break
280 + case 'finished':
281 + break
282 + }
283 + }
284 + patchMessage((m) => m, true) // final persist
285 + void autoTitleIfNeeded(conversationID)
286 + } catch (err) {
287 + const cancelled = err instanceof ProviderError && err.kind === 'cancelled'
288 + if (!cancelled) {
289 + const text = err instanceof Error ? err.message : String(err)
290 + patchMessage((m) => ({ ...m, errorText: text }), true)
291 + get().toast(text, 'error')
292 + } else {
293 + patchMessage((m) => m, true)
294 + }
295 + } finally {
296 + set((s) => {
297 + const streaming = { ...s.streaming }
298 + delete streaming[conversationID]
299 + return { streaming }
300 + })
301 + }
302 + }
303 +
304 + /** Native auto-title behavior: after the first completed exchange. */
305 + const autoTitleIfNeeded = async (conversationID: string) => {
306 + const conversation = get().conversations.find((c) => c.id === conversationID)
307 + if (!conversation || !conversation.hasAutoTitle) return
308 + const assistantTurns = conversation.messages.filter(
309 + (m) => m.role === 'assistant' && m.text.trim() !== ''
310 + )
311 + if (assistantTurns.length !== 1) return
312 + const model = cheapestModel(conversation.provider)
313 + const apiKey = model ? getKey(model.provider) : undefined
314 + if (!model || !apiKey) return
315 + const firstUser = conversation.messages.find((m) => m.role === 'user')?.text ?? ''
316 + const lastAssistant = assistantTurns[assistantTurns.length - 1]?.text ?? ''
317 + try {
318 + const result = await clientFor(model).complete(
319 + {
320 + model,
321 + messages: [
322 + {
323 + id: uid(),
324 + role: 'user',
325 + text:
326 + 'Write a title of at most 5 words for this conversation. Reply with the title only, no quotes.\n' +
327 + `User: ${firstUser.slice(0, 500)}\nAssistant: ${lastAssistant.slice(0, 500)}`,
328 + createdAt: Date.now(),
329 + },
330 + ],
331 + parameters: { maxTokens: 24 },
332 + stream: false,
333 + },
334 + apiKey
335 + )
336 + const title = result.text
337 + .trim()
338 + .replace(/^["'“”]+|["'“”]+$/g, '')
339 + .slice(0, 60)
340 + const current = get().conversations.find((c) => c.id === conversationID)
341 + if (title !== '' && current?.hasAutoTitle) {
342 + mutateConversation(conversationID, (c) => ({ ...c, title }))
343 + }
344 + } catch {
345 + // Silent failure — title stays "New Chat".
346 + }
347 + }
348 +
349 + return {
350 + settings: initialSettings,
351 + updateSettings: (patch) => {
352 + const settings = { ...get().settings, ...patch }
353 + saveSettings(settings)
354 + applyThemeToDocument(settings)
355 + set({ settings })
356 + },
357 +
358 + keyedProviders: [],
359 + refreshKeyedProviders: () => set({ keyedProviders: configuredProviders() }),
360 + vaultLocked: isLocked(),
361 + setVaultLocked: (locked) => set({ vaultLocked: locked }),
362 +
363 + conversations: [],
364 + selectedID: null,
365 + searchText: '',
366 + loaded: false,
367 + loadAll: async () => {
368 + const conversations = await loadConversations()
369 + set({
370 + conversations,
371 + loaded: true,
372 + selectedID: conversations[0]?.id ?? null,
373 + keyedProviders: configuredProviders(),
374 + })
375 + },
376 + select: (id) => set({ selectedID: id, sidebarOpen: false }),
377 + setSearchText: (text) => set({ searchText: text }),
378 +
379 + newConversation: (opts = {}) => {
380 + const settings = get().settings
381 + const model =
382 + opts.model ??
383 + findModel(settings.defaultProvider, settings.defaultModelID) ??
384 + defaultModel()
385 + const systemPrompt = opts.systemPrompt ?? settings.defaultSystemPrompt
386 + const conversation: Conversation = {
387 + id: uid(),
388 + title: 'New Chat',
389 + createdAt: Date.now(),
390 + updatedAt: Date.now(),
391 + modelID: model.id,
392 + provider: model.provider,
393 + ...(systemPrompt !== '' ? { systemPrompt } : {}),
394 + parameters: opts.parameters ?? { ...settings.defaultParameters },
395 + messages: [],
396 + pinned: false,
397 + hasAutoTitle: true,
398 + ...(opts.personaID ? { personaID: opts.personaID } : {}),
399 + }
400 + set((s) => ({
401 + conversations: [conversation, ...s.conversations],
402 + selectedID: conversation.id,
403 + sidebarOpen: false,
404 + }))
405 + persist(conversation)
406 + return conversation
407 + },
408 +
409 + updateConversation: (id, patch) => {
410 + mutateConversation(id, (c) => ({ ...c, ...patch, updatedAt: Date.now() }))
411 + },
412 +
413 + removeConversation: (id) => {
414 + const conversation = get().conversations.find((c) => c.id === id)
415 + get().stop(id)
416 + set((s) => {
417 + const conversations = s.conversations.filter((c) => c.id !== id)
418 + return {
419 + conversations,
420 + selectedID: s.selectedID === id ? (conversations[0]?.id ?? null) : s.selectedID,
421 + }
422 + })
423 + void dbDeleteConversation(id)
424 + if (conversation) {
425 + get().toast(`Deleted “${conversation.title}”`, 'info', () =>
426 + get().restoreConversation(conversation)
427 + )
428 + }
429 + },
430 +
431 + restoreConversation: (conversation) => {
432 + set((s) => ({
433 + conversations: [conversation, ...s.conversations].sort(
434 + (a, b) => b.updatedAt - a.updatedAt
435 + ),
436 + selectedID: conversation.id,
437 + }))
438 + persist(conversation)
439 + },
440 +
441 + send: async (conversationID, text, attachments, overrideModel) => {
442 + const conversation = get().conversations.find((c) => c.id === conversationID)
443 + if (!conversation) return
444 + const model = overrideModel ?? findModel(conversation.provider, conversation.modelID)
445 + const userMessage: Message = {
446 + id: uid(),
447 + role: 'user',
448 + text,
449 + ...(attachments.length > 0 ? { attachments } : {}),
450 + ...(model ? { modelID: model.id, provider: model.provider } : {}),
451 + createdAt: Date.now(),
452 + }
453 + mutateConversation(conversationID, (c) => ({
454 + ...c,
455 + messages: [...c.messages, userMessage],
456 + updatedAt: Date.now(),
457 + }))
458 + await generateReply(conversationID, overrideModel)
459 + },
460 +
461 + regenerate: async (conversationID, messageID, overrideModel) => {
462 + const conversation = get().conversations.find((c) => c.id === conversationID)
463 + if (!conversation) return
464 + const index = conversation.messages.findIndex((m) => m.id === messageID)
465 + const message = conversation.messages[index]
466 + if (!message || message.role !== 'assistant') return
467 + // Web improvement over native: keep the old answer as a variant instead
468 + // of dropping it, unless it was empty/errored.
469 + const keepVariant = message.text.trim() !== '' && !message.errorText
470 + if (index === conversation.messages.length - 1) {
471 + mutateConversation(conversationID, (c) => {
472 + const messages = c.messages.slice(0, -1)
473 + return { ...c, messages }
474 + })
475 + const old: MessageVariant | null = keepVariant
476 + ? {
477 + text: message.text,
478 + ...(message.reasoning ? { reasoning: message.reasoning } : {}),
479 + ...(message.citations ? { citations: message.citations } : {}),
480 + ...(message.modelID ? { modelID: message.modelID } : {}),
481 + ...(message.provider ? { provider: message.provider } : {}),
482 + ...(message.usage ? { usage: message.usage } : {}),
483 + ...(message.estimatedCost !== undefined
484 + ? { estimatedCost: message.estimatedCost }
485 + : {}),
486 + createdAt: message.createdAt,
487 + }
488 + : null
489 + await generateReply(conversationID, overrideModel)
490 + if (old) {
491 + // Attach the previous answer as a variant of the fresh message.
492 + const fresh = get().conversations.find((c) => c.id === conversationID)
493 + const last = fresh?.messages[fresh.messages.length - 1]
494 + if (last && last.role === 'assistant') {
495 + mutateConversation(conversationID, (c) => ({
496 + ...c,
497 + messages: c.messages.map((m) =>
498 + m.id === last.id
499 + ? {
500 + ...m,
501 + variants: [...(message.variants ?? []), old],
502 + }
503 + : m
504 + ),
505 + }))
506 + }
507 + }
508 + }
509 + },
510 +
511 + editAndResend: async (conversationID, messageID, newText) => {
512 + const conversation = get().conversations.find((c) => c.id === conversationID)
513 + if (!conversation) return
514 + const index = conversation.messages.findIndex((m) => m.id === messageID)
515 + if (index === -1) return
516 + mutateConversation(conversationID, (c) => ({
517 + ...c,
518 + messages: c.messages
519 + .slice(0, index + 1)
520 + .map((m) => (m.id === messageID ? { ...m, text: newText } : m)),
521 + updatedAt: Date.now(),
522 + }))
523 + await generateReply(conversationID)
524 + },
525 +
526 + deleteMessage: (conversationID, messageID) => {
527 + mutateConversation(conversationID, (c) => ({
528 + ...c,
529 + messages: c.messages.filter((m) => m.id !== messageID),
530 + updatedAt: Date.now(),
531 + }))
532 + },
533 +
534 + stop: (conversationID) => {
535 + const st = get().streaming[conversationID]
536 + if (st) st.abort.abort()
537 + },
538 +
539 + continueGeneration: async (conversationID) => {
540 + const conversation = get().conversations.find((c) => c.id === conversationID)
541 + if (!conversation) return
542 + const continueMessage: Message = {
543 + id: uid(),
544 + role: 'user',
545 + text: 'Continue exactly where you left off.',
546 + createdAt: Date.now(),
547 + }
548 + mutateConversation(conversationID, (c) => ({
549 + ...c,
550 + messages: [...c.messages, continueMessage],
551 + updatedAt: Date.now(),
552 + }))
553 + await generateReply(conversationID)
554 + },
555 +
556 + branchFrom: (conversationID, messageID) => {
557 + const conversation = get().conversations.find((c) => c.id === conversationID)
558 + if (!conversation) return null
559 + const index = conversation.messages.findIndex((m) => m.id === messageID)
560 + if (index === -1) return null
561 + const branch: Conversation = {
562 + ...conversation,
563 + id: uid(),
564 + title: `${conversation.title} (branch)`,
565 + createdAt: Date.now(),
566 + updatedAt: Date.now(),
567 + messages: conversation.messages.slice(0, index + 1).map((m) => ({ ...m, id: uid() })),
568 + pinned: false,
569 + hasAutoTitle: false,
570 + branchedFrom: { conversationID, messageID },
571 + }
572 + set((s) => ({
573 + conversations: [branch, ...s.conversations],
574 + selectedID: branch.id,
575 + }))
576 + persist(branch)
577 + return branch
578 + },
579 +
580 + setActiveVariant: (conversationID, messageID, index) => {
581 + mutateConversation(conversationID, (c) => ({
582 + ...c,
583 + messages: c.messages.map((m) => {
584 + if (m.id !== messageID || !m.variants) return m
585 + const variants = m.variants
586 + const target = index === -1 ? null : variants[index]
587 + if (index === -1 || !target) return { ...m, activeVariant: undefined }
588 + // Swap: current content goes into the variant slot, variant becomes active.
589 + return { ...m, activeVariant: index }
590 + }),
591 + }))
592 + },
593 +
594 + streaming: {},
595 +
596 + draftSeed: null,
597 + setDraftSeed: (seed) => set({ draftSeed: seed }),
598 + sidebarOpen: false,
599 + setSidebarOpen: (open) => set({ sidebarOpen: open }),
600 + modelMenuOpen: false,
601 + setModelMenuOpen: (open) => set({ modelMenuOpen: open }),
602 + settingsOpen: false,
603 + settingsTab: 'providers',
604 + openSettings: (tab = 'providers') => set({ settingsOpen: true, settingsTab: tab }),
605 + closeSettings: () => set({ settingsOpen: false }),
606 + paletteOpen: false,
607 + setPaletteOpen: (open) => set({ paletteOpen: open }),
608 + compareOpen: false,
609 + setCompareOpen: (open) => set({ compareOpen: open }),
610 +
611 + toasts: [],
612 + toast: (text, kind = 'info', undo) => {
613 + const id = uid()
614 + set((s) => ({ toasts: [...s.toasts, { id, text, kind, ...(undo ? { undo } : {}) }] }))
615 + setTimeout(() => get().dismissToast(id), 6000)
616 + },
617 + dismissToast: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
618 + }
619 +})
modified tsconfig.app.tsbuildinfo +1 −1
@@ -1 +1 @@
1 {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/features/streamtestview.tsx","./src/features/devharness.ts","./src/providers/anthropic.ts","./src/providers/catalog.ts","./src/providers/openaicompatible.ts","./src/providers/registry.ts","./src/providers/sse.ts","./src/providers/testkey.ts","./src/providers/types.ts","./src/storage/backup.ts","./src/storage/conversations.ts","./src/storage/db.ts","./src/storage/keys.ts","./src/storage/settings.ts","./src/storage/vault.ts","./src/types/index.ts"],"version":"5.9.3"}
\ No newline at end of file
1 +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/chatview.tsx","./src/components/firstrun.tsx","./src/components/inputbar.tsx","./src/components/markdown.tsx","./src/components/messagebubble.tsx","./src/components/modelmenu.tsx","./src/components/paramspanel.tsx","./src/components/settingsmodal.tsx","./src/components/sidebar.tsx","./src/components/suggestiongrid.tsx","./src/components/toasts.tsx","./src/components/zyquoglyph.tsx","./src/components/icons.tsx","./src/features/streamtestview.tsx","./src/features/cataloghelpers.ts","./src/features/devharness.ts","./src/features/exporters.ts","./src/providers/anthropic.ts","./src/providers/catalog.ts","./src/providers/openaicompatible.ts","./src/providers/registry.ts","./src/providers/sse.ts","./src/providers/testkey.ts","./src/providers/types.ts","./src/state/store.ts","./src/storage/backup.ts","./src/storage/conversations.ts","./src/storage/db.ts","./src/storage/keys.ts","./src/storage/settings.ts","./src/storage/vault.ts","./src/types/index.ts"],"version":"5.9.3"}
\ No newline at end of file
2 2