From eea5b262cbafb67a1c2a916f48e6462b1de15009 Mon Sep 17 00:00:00 2001 From: jules Date: Tue, 28 Apr 2026 14:29:58 +1000 Subject: [PATCH] feat: appbar pickers, multi-agent personas, threads, library, profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a substantial chrome layer atop the bare template: Appbar - Font-size picker (root font-size scale): S/M/L/XL - Surface picker: tints --background/--card/--popover/--sidebar/--muted/ --secondary/--accent across light + dark - Background picker: 11 atmospheric gradients (Pearl, Linen, Mist, Dawn, Seafoam, Aurora, Sunset, Meadow, Midnight, Blush, Noir) + None - Vivid foreground tokens for stronger text contrast; fixed dark-mode blue-on-blue user bubble (deeper --primary, near-white --primary-fg) Assistant route - Multi-agent personas (~/lib/agents.ts): Atlas, Forge, Inkwell, Pilot, Cursor — each with name/role/sub-prompt; per-thread persona; agent picker with avatar tint + handoff submenu - Conversation threads (~/lib/threads.ts): new/switch/rename/delete, auto-titling from first user message, per-thread pinned indices - Compact summarization with snapshot-based Restore that preserves pinned messages verbatim - Edit & retry the last user message, Regenerate, Continue, Show system prompt, Copy / Export Markdown, Save to Library, Compare across agents (parallel completions in a side-by-side modal) - Per-message Pin / Read aloud (Web Speech) / Edit - Voice input via Web Speech Recognition - Two-column Actions popover (UI Control + Conversation / Share / Multi-agent / Clear sections) - Status bar: connection dot + LOCAL/API/MOCK chip + host chip + context progress bar - Compactly named threads picker; New conversation - DropdownMenuItem onSelect → onClick (base-ui Menu fires onClick) Library - ~/lib/library.ts store, /library route with search + detail panel (Copy / Download / Delete) Profile - /profile route + ~/lib/profile.ts (name/email/title/bio/signature/ avatar dataURL/default agent), AppShell uses live profile for the appbar avatar; account menu now navigates to /profile Settings - Sub-sidenav (LLM / Agents / Appearance / Account / About) - Editable system prompt with reset-to-default - Agents CRUD panel - Reorganized layout UI Control - Static action catalog in the system prompt so the assistant can drive controls on routes that aren't currently mounted - Always returns to /assistant after a UI Control sequence (model- side rule + deterministic safety net) - Cursor uses click-nav over direct navigate so the virtual cursor is visibly involved - New ids tagged across the app (sidebar, settings, profile, library, assistant tools, agent handoff, thread management) Hydration - root.tsx: suppressHydrationWarning on html/body since the pre-mount script sets dark/data-bg/data-surface/data-font-scale before React Co-Authored-By: Claude Opus 4.7 (1M context) --- app/app.css | 382 ++++ app/components/assistant/message-body.tsx | 2 +- app/components/layout/app-shell.tsx | 42 +- app/components/layout/background-picker.tsx | 112 ++ app/components/layout/font-size-picker.tsx | 104 ++ app/components/layout/surface-picker.tsx | 107 ++ app/lib/agents.ts | 153 ++ app/lib/library.ts | 125 ++ app/lib/llm-settings.ts | 9 + app/lib/profile.ts | 115 ++ app/lib/threads.ts | 217 +++ app/root.tsx | 6 +- app/routes.ts | 1 + app/routes/assistant.tsx | 1838 +++++++++++++++++-- app/routes/library.tsx | 192 +- app/routes/profile.tsx | 288 +++ app/routes/settings.tsx | 573 ++++-- 17 files changed, 3995 insertions(+), 271 deletions(-) create mode 100644 app/components/layout/background-picker.tsx create mode 100644 app/components/layout/font-size-picker.tsx create mode 100644 app/components/layout/surface-picker.tsx create mode 100644 app/lib/agents.ts create mode 100644 app/lib/library.ts create mode 100644 app/lib/profile.ts create mode 100644 app/lib/threads.ts create mode 100644 app/routes/profile.tsx diff --git a/app/app.css b/app/app.css index 0c589b2..98089e7 100644 --- a/app/app.css +++ b/app/app.css @@ -113,4 +113,386 @@ font-size: 18px; overscroll-behavior-y: none; } + html[data-font-scale="sm"] { font-size: 16px; } + html[data-font-scale="md"] { font-size: 18px; } + html[data-font-scale="lg"] { font-size: 20px; } + html[data-font-scale="xl"] { font-size: 22px; } +} + +/* ── Vivid text ── + * Boost foreground tokens for more contrast + chroma than base mightypix. + * NOT wrapped in @layer base — mightypix's `.dark{…}` block is unlayered, + * which beats any layer. We use `html:root` / `html.dark` for matching + * specificity and unlayered placement so the cascade resolves by source order. */ +html:root { + --foreground: oklch(0.16 0.08 255); + --card-foreground: oklch(0.16 0.08 255); + --popover-foreground: oklch(0.16 0.08 255); + --sidebar-foreground: oklch(0.16 0.08 255); + --muted-foreground: oklch(0.38 0.07 255); + --secondary-foreground: oklch(0.22 0.09 255); + --accent-foreground: oklch(0.22 0.09 255); +} +html.dark { + --foreground: oklch(0.98 0.025 80); + --card-foreground: oklch(0.98 0.025 80); + --popover-foreground: oklch(0.98 0.025 80); + --sidebar-foreground: oklch(0.98 0.025 80); + --muted-foreground: oklch(0.84 0.04 80); + --secondary-foreground: oklch(0.96 0.03 80); + --accent-foreground: oklch(0.96 0.03 80); + /* Darker primary so the user chat bubble is deep blue, not bright blue. */ + --primary: oklch(0.48 0.18 255); + /* Near-white text on filled buttons / user chat bubble (was dark-blue). */ + --primary-foreground: oklch(0.99 0.01 80); + /* Deepen muted so assistant bubble (bg-muted) reads cleanly. */ + --muted: oklch(0.22 0.022 250); + --secondary: oklch(0.24 0.025 250); +} + +/* ── Surface tints ── + * Override card / popover / sidebar tokens via `body[data-surface=""]`. + * Light values are used in normal mode; dark values when `html.dark` is set. + * Swatch preview classes (`.surface-swatch-`) sample the same hues. */ +@layer base { + body[data-surface="snow"] { + --foreground: oklch(0.14 0.09 255); + --card-foreground: oklch(0.14 0.09 255); + --popover-foreground: oklch(0.14 0.09 255); + --sidebar-foreground: oklch(0.14 0.09 255); + --muted-foreground: oklch(0.36 0.08 255); + --background: oklch(1 0 0); + --card: oklch(0.99 0.002 250); + --popover: oklch(1 0 0); + --muted: oklch(0.96 0.004 250); + --secondary: oklch(0.95 0.005 250); + --accent: oklch(0.94 0.006 250); + --sidebar: oklch(0.98 0.003 250); + --sidebar-accent: oklch(0.94 0.005 250); + } + body[data-surface="stone"] { + --foreground: oklch(0.18 0.1 40); + --card-foreground: oklch(0.18 0.1 40); + --popover-foreground: oklch(0.18 0.1 40); + --sidebar-foreground: oklch(0.18 0.1 40); + --muted-foreground: oklch(0.4 0.08 40); + --background: oklch(0.97 0.012 65); + --card: oklch(0.985 0.008 60); + --popover: oklch(0.99 0.006 60); + --muted: oklch(0.94 0.016 65); + --secondary: oklch(0.93 0.02 65); + --accent: oklch(0.91 0.024 65); + --sidebar: oklch(0.95 0.014 65); + --sidebar-accent: oklch(0.9 0.022 65); + } + body[data-surface="sage"] { + --foreground: oklch(0.18 0.1 155); + --card-foreground: oklch(0.18 0.1 155); + --popover-foreground: oklch(0.18 0.1 155); + --sidebar-foreground: oklch(0.18 0.1 155); + --muted-foreground: oklch(0.4 0.08 155); + --background: oklch(0.97 0.022 155); + --card: oklch(0.985 0.014 155); + --popover: oklch(0.99 0.012 155); + --muted: oklch(0.94 0.025 155); + --secondary: oklch(0.93 0.028 155); + --accent: oklch(0.91 0.032 155); + --sidebar: oklch(0.95 0.024 155); + --sidebar-accent: oklch(0.9 0.034 155); + } + body[data-surface="slate"] { + --foreground: oklch(0.16 0.11 240); + --card-foreground: oklch(0.16 0.11 240); + --popover-foreground: oklch(0.16 0.11 240); + --sidebar-foreground: oklch(0.16 0.11 240); + --muted-foreground: oklch(0.38 0.09 240); + --background: oklch(0.96 0.014 240); + --card: oklch(0.98 0.01 240); + --popover: oklch(0.99 0.008 240); + --muted: oklch(0.93 0.018 240); + --secondary: oklch(0.92 0.022 240); + --accent: oklch(0.9 0.026 240); + --sidebar: oklch(0.94 0.018 240); + --sidebar-accent: oklch(0.89 0.026 240); + } + + html.dark body[data-surface="snow"] { + --foreground: oklch(0.98 0.02 250); + --card-foreground: oklch(0.98 0.02 250); + --popover-foreground: oklch(0.98 0.02 250); + --sidebar-foreground: oklch(0.98 0.02 250); + --muted-foreground: oklch(0.84 0.03 250); + --background: oklch(0.18 0.004 250); + --card: oklch(0.24 0.005 250); + --popover: oklch(0.24 0.005 250); + --muted: oklch(0.26 0.006 250); + --secondary: oklch(0.28 0.007 250); + --accent: oklch(0.3 0.008 250); + --sidebar: oklch(0.16 0.004 250); + --sidebar-accent: oklch(0.26 0.007 250); + } + html.dark body[data-surface="stone"] { + --foreground: oklch(0.98 0.04 80); + --card-foreground: oklch(0.98 0.04 80); + --popover-foreground: oklch(0.98 0.04 80); + --sidebar-foreground: oklch(0.98 0.04 80); + --muted-foreground: oklch(0.84 0.05 80); + --background: oklch(0.18 0.016 60); + --card: oklch(0.24 0.014 60); + --popover: oklch(0.24 0.014 60); + --muted: oklch(0.26 0.018 60); + --secondary: oklch(0.28 0.02 60); + --accent: oklch(0.3 0.022 60); + --sidebar: oklch(0.16 0.018 60); + --sidebar-accent: oklch(0.27 0.022 60); + } + html.dark body[data-surface="sage"] { + --foreground: oklch(0.98 0.04 145); + --card-foreground: oklch(0.98 0.04 145); + --popover-foreground: oklch(0.98 0.04 145); + --sidebar-foreground: oklch(0.98 0.04 145); + --muted-foreground: oklch(0.84 0.05 145); + --background: oklch(0.18 0.025 155); + --card: oklch(0.24 0.022 155); + --popover: oklch(0.24 0.02 155); + --muted: oklch(0.26 0.028 155); + --secondary: oklch(0.28 0.03 155); + --accent: oklch(0.3 0.032 155); + --sidebar: oklch(0.16 0.026 155); + --sidebar-accent: oklch(0.27 0.032 155); + } + html.dark body[data-surface="slate"] { + --foreground: oklch(0.98 0.025 240); + --card-foreground: oklch(0.98 0.025 240); + --popover-foreground: oklch(0.98 0.025 240); + --sidebar-foreground: oklch(0.98 0.025 240); + --muted-foreground: oklch(0.84 0.04 240); + --background: oklch(0.18 0.02 240); + --card: oklch(0.24 0.018 240); + --popover: oklch(0.24 0.016 240); + --muted: oklch(0.26 0.024 240); + --secondary: oklch(0.28 0.026 240); + --accent: oklch(0.3 0.028 240); + --sidebar: oklch(0.16 0.022 240); + --sidebar-accent: oklch(0.27 0.028 240); + } + + .surface-swatch-default { background: var(--card); } + .surface-swatch-snow { background: oklch(1 0 0); } + .surface-swatch-stone { background: oklch(0.97 0.008 60); } + .surface-swatch-sage { background: oklch(0.97 0.018 155); } + .surface-swatch-slate { background: oklch(0.96 0.012 240); } + html.dark .surface-swatch-snow { background: oklch(0.24 0.005 250); } + html.dark .surface-swatch-stone { background: oklch(0.24 0.014 60); } + html.dark .surface-swatch-sage { background: oklch(0.24 0.022 155); } + html.dark .surface-swatch-slate { background: oklch(0.24 0.018 240); } +} + +/* ── Background atmospheres ── + * Selected via `body[data-bg=""]` (set by BackgroundPicker). + * Default (no attribute) keeps the theme's flat surface. */ +@layer base { + /* ── Light, airy variants ── */ + body[data-bg="pearl"], + .bg-variant-pearl { + background-image: + radial-gradient(ellipse 70% 60% at 18% 12%, oklch(0.98 0.02 320 / 0.85), transparent 60%), + radial-gradient(ellipse 60% 50% at 82% 18%, oklch(0.97 0.03 220 / 0.75), transparent 55%), + radial-gradient(ellipse 80% 60% at 88% 88%, oklch(0.98 0.025 260 / 0.7), transparent 60%), + radial-gradient(ellipse 60% 50% at 12% 90%, oklch(0.99 0.02 60 / 0.65), transparent 55%), + linear-gradient(180deg, oklch(0.99 0.005 280), oklch(0.97 0.012 240)); + } + body[data-bg="linen"], + .bg-variant-linen { + background-image: + radial-gradient(ellipse 70% 60% at 15% 12%, oklch(0.98 0.03 80 / 0.8), transparent 60%), + radial-gradient(ellipse 60% 50% at 85% 18%, oklch(0.97 0.035 60 / 0.75), transparent 55%), + radial-gradient(ellipse 80% 60% at 90% 88%, oklch(0.98 0.025 40 / 0.7), transparent 60%), + radial-gradient(ellipse 60% 50% at 10% 90%, oklch(0.99 0.02 90 / 0.65), transparent 55%), + linear-gradient(180deg, oklch(0.99 0.012 75), oklch(0.97 0.018 60)); + } + body[data-bg="mist"], + .bg-variant-mist { + background-image: + radial-gradient(ellipse 70% 60% at 18% 14%, oklch(0.97 0.02 220 / 0.85), transparent 60%), + radial-gradient(ellipse 60% 50% at 82% 16%, oklch(0.96 0.025 200 / 0.75), transparent 55%), + radial-gradient(ellipse 80% 60% at 88% 88%, oklch(0.97 0.02 240 / 0.7), transparent 60%), + radial-gradient(ellipse 60% 50% at 12% 90%, oklch(0.98 0.018 180 / 0.65), transparent 55%), + linear-gradient(180deg, oklch(0.98 0.008 220), oklch(0.96 0.012 210)); + } + body[data-bg="dawn"], + .bg-variant-dawn { + background-image: + radial-gradient(ellipse 70% 60% at 15% 12%, oklch(0.97 0.04 30 / 0.8), transparent 60%), + radial-gradient(ellipse 60% 50% at 85% 18%, oklch(0.96 0.035 320 / 0.75), transparent 55%), + radial-gradient(ellipse 80% 60% at 90% 88%, oklch(0.97 0.03 50 / 0.7), transparent 60%), + radial-gradient(ellipse 60% 50% at 10% 90%, oklch(0.98 0.025 290 / 0.65), transparent 55%), + linear-gradient(180deg, oklch(0.99 0.012 40), oklch(0.97 0.018 320)); + } + body[data-bg="seafoam"], + .bg-variant-seafoam { + background-image: + radial-gradient(ellipse 70% 60% at 15% 14%, oklch(0.97 0.035 180 / 0.85), transparent 60%), + radial-gradient(ellipse 60% 50% at 85% 18%, oklch(0.96 0.03 160 / 0.75), transparent 55%), + radial-gradient(ellipse 80% 60% at 88% 88%, oklch(0.97 0.03 200 / 0.7), transparent 60%), + radial-gradient(ellipse 60% 50% at 12% 90%, oklch(0.98 0.025 145 / 0.65), transparent 55%), + linear-gradient(180deg, oklch(0.98 0.012 175), oklch(0.96 0.018 165)); + } + + body[data-bg="aurora"], + .bg-variant-aurora { + background-image: + radial-gradient(ellipse 70% 60% at 15% 10%, oklch(0.88 0.11 250 / 0.85), transparent 60%), + radial-gradient(ellipse 60% 50% at 85% 20%, oklch(0.9 0.1 320 / 0.75), transparent 55%), + radial-gradient(ellipse 80% 60% at 90% 85%, oklch(0.9 0.12 200 / 0.7), transparent 60%), + radial-gradient(ellipse 60% 50% at 10% 90%, oklch(0.92 0.09 145 / 0.65), transparent 55%), + linear-gradient(180deg, oklch(0.97 0.02 260), oklch(0.94 0.03 280)); + } + body[data-bg="sunset"], + .bg-variant-sunset { + background-image: + radial-gradient(ellipse 70% 60% at 12% 15%, oklch(0.9 0.13 50 / 0.85), transparent 60%), + radial-gradient(ellipse 65% 55% at 85% 10%, oklch(0.88 0.14 20 / 0.8), transparent 55%), + radial-gradient(ellipse 80% 60% at 90% 90%, oklch(0.9 0.12 340 / 0.7), transparent 60%), + radial-gradient(ellipse 60% 50% at 10% 85%, oklch(0.92 0.1 75 / 0.7), transparent 55%), + linear-gradient(180deg, oklch(0.97 0.03 60), oklch(0.94 0.04 30)); + } + body[data-bg="meadow"], + .bg-variant-meadow { + background-image: + radial-gradient(ellipse 70% 60% at 15% 12%, oklch(0.9 0.12 145 / 0.85), transparent 60%), + radial-gradient(ellipse 60% 50% at 85% 18%, oklch(0.91 0.11 180 / 0.75), transparent 55%), + radial-gradient(ellipse 80% 60% at 90% 88%, oklch(0.9 0.12 120 / 0.7), transparent 60%), + radial-gradient(ellipse 60% 50% at 10% 90%, oklch(0.93 0.1 95 / 0.65), transparent 55%), + linear-gradient(180deg, oklch(0.97 0.03 155), oklch(0.94 0.04 170)); + } + body[data-bg="midnight"], + .bg-variant-midnight { + background-image: + radial-gradient(ellipse 70% 60% at 18% 12%, oklch(0.58 0.19 270 / 0.9), transparent 60%), + radial-gradient(ellipse 65% 55% at 85% 20%, oklch(0.55 0.22 310 / 0.85), transparent 55%), + radial-gradient(ellipse 80% 60% at 90% 85%, oklch(0.5 0.22 240 / 0.8), transparent 60%), + radial-gradient(ellipse 60% 50% at 10% 90%, oklch(0.55 0.2 330 / 0.75), transparent 55%), + linear-gradient(180deg, oklch(0.35 0.12 265), oklch(0.28 0.14 290)); + } + body[data-bg="blush"], + .bg-variant-blush { + background-image: + radial-gradient(ellipse 70% 60% at 15% 15%, oklch(0.92 0.1 10 / 0.85), transparent 60%), + radial-gradient(ellipse 60% 50% at 85% 15%, oklch(0.93 0.09 340 / 0.8), transparent 55%), + radial-gradient(ellipse 80% 60% at 88% 88%, oklch(0.92 0.1 300 / 0.7), transparent 60%), + radial-gradient(ellipse 60% 50% at 12% 88%, oklch(0.94 0.08 45 / 0.7), transparent 55%), + linear-gradient(180deg, oklch(0.98 0.02 20), oklch(0.95 0.03 355)); + } + body[data-bg="noir"], + .bg-variant-noir { + background-image: + radial-gradient(ellipse 70% 60% at 15% 10%, oklch(0.85 0.01 260 / 0.9), transparent 60%), + radial-gradient(ellipse 60% 50% at 85% 20%, oklch(0.82 0.01 280 / 0.85), transparent 55%), + radial-gradient(ellipse 80% 60% at 90% 85%, oklch(0.8 0.01 240 / 0.8), transparent 60%), + radial-gradient(ellipse 60% 50% at 10% 90%, oklch(0.84 0.01 200 / 0.75), transparent 55%), + linear-gradient(180deg, oklch(0.95 0.005 260), oklch(0.9 0.005 260)); + } + + /* ── Dark mode: same hues, deeper L, lower alpha ── */ + html.dark body[data-bg="pearl"], + html.dark .bg-variant-pearl { + background-image: + radial-gradient(ellipse 70% 60% at 18% 12%, oklch(0.42 0.06 320 / 0.55), transparent 60%), + radial-gradient(ellipse 60% 50% at 82% 18%, oklch(0.4 0.07 220 / 0.5), transparent 55%), + radial-gradient(ellipse 80% 60% at 88% 88%, oklch(0.4 0.06 260 / 0.45), transparent 60%), + radial-gradient(ellipse 60% 50% at 12% 90%, oklch(0.42 0.05 60 / 0.4), transparent 55%), + linear-gradient(180deg, oklch(0.18 0.015 280), oklch(0.15 0.02 240)); + } + html.dark body[data-bg="linen"], + html.dark .bg-variant-linen { + background-image: + radial-gradient(ellipse 70% 60% at 15% 12%, oklch(0.42 0.07 80 / 0.55), transparent 60%), + radial-gradient(ellipse 60% 50% at 85% 18%, oklch(0.4 0.08 60 / 0.5), transparent 55%), + radial-gradient(ellipse 80% 60% at 90% 88%, oklch(0.42 0.07 40 / 0.45), transparent 60%), + radial-gradient(ellipse 60% 50% at 10% 90%, oklch(0.42 0.06 90 / 0.4), transparent 55%), + linear-gradient(180deg, oklch(0.18 0.025 75), oklch(0.15 0.03 60)); + } + html.dark body[data-bg="mist"], + html.dark .bg-variant-mist { + background-image: + radial-gradient(ellipse 70% 60% at 18% 14%, oklch(0.4 0.06 220 / 0.55), transparent 60%), + radial-gradient(ellipse 60% 50% at 82% 16%, oklch(0.4 0.07 200 / 0.5), transparent 55%), + radial-gradient(ellipse 80% 60% at 88% 88%, oklch(0.4 0.06 240 / 0.45), transparent 60%), + radial-gradient(ellipse 60% 50% at 12% 90%, oklch(0.42 0.05 180 / 0.4), transparent 55%), + linear-gradient(180deg, oklch(0.17 0.018 220), oklch(0.14 0.022 210)); + } + html.dark body[data-bg="dawn"], + html.dark .bg-variant-dawn { + background-image: + radial-gradient(ellipse 70% 60% at 15% 12%, oklch(0.42 0.1 30 / 0.55), transparent 60%), + radial-gradient(ellipse 60% 50% at 85% 18%, oklch(0.4 0.1 320 / 0.5), transparent 55%), + radial-gradient(ellipse 80% 60% at 90% 88%, oklch(0.42 0.09 50 / 0.45), transparent 60%), + radial-gradient(ellipse 60% 50% at 10% 90%, oklch(0.42 0.08 290 / 0.4), transparent 55%), + linear-gradient(180deg, oklch(0.18 0.03 40), oklch(0.15 0.035 320)); + } + html.dark body[data-bg="seafoam"], + html.dark .bg-variant-seafoam { + background-image: + radial-gradient(ellipse 70% 60% at 15% 14%, oklch(0.42 0.08 180 / 0.55), transparent 60%), + radial-gradient(ellipse 60% 50% at 85% 18%, oklch(0.4 0.08 160 / 0.5), transparent 55%), + radial-gradient(ellipse 80% 60% at 88% 88%, oklch(0.4 0.08 200 / 0.45), transparent 60%), + radial-gradient(ellipse 60% 50% at 12% 90%, oklch(0.42 0.07 145 / 0.4), transparent 55%), + linear-gradient(180deg, oklch(0.18 0.025 175), oklch(0.15 0.03 165)); + } + + html.dark body[data-bg="aurora"], + html.dark .bg-variant-aurora { + background-image: + radial-gradient(ellipse 70% 60% at 15% 10%, oklch(0.45 0.18 250 / 0.7), transparent 60%), + radial-gradient(ellipse 60% 50% at 85% 20%, oklch(0.42 0.2 320 / 0.65), transparent 55%), + radial-gradient(ellipse 80% 60% at 90% 85%, oklch(0.4 0.18 200 / 0.6), transparent 60%), + radial-gradient(ellipse 60% 50% at 10% 90%, oklch(0.42 0.16 145 / 0.55), transparent 55%), + linear-gradient(180deg, oklch(0.16 0.04 260), oklch(0.13 0.05 280)); + } + html.dark body[data-bg="sunset"], + html.dark .bg-variant-sunset { + background-image: + radial-gradient(ellipse 70% 60% at 12% 15%, oklch(0.5 0.18 50 / 0.7), transparent 60%), + radial-gradient(ellipse 65% 55% at 85% 10%, oklch(0.48 0.2 20 / 0.65), transparent 55%), + radial-gradient(ellipse 80% 60% at 90% 90%, oklch(0.45 0.18 340 / 0.6), transparent 60%), + radial-gradient(ellipse 60% 50% at 10% 85%, oklch(0.5 0.16 75 / 0.55), transparent 55%), + linear-gradient(180deg, oklch(0.18 0.06 30), oklch(0.14 0.08 15)); + } + html.dark body[data-bg="meadow"], + html.dark .bg-variant-meadow { + background-image: + radial-gradient(ellipse 70% 60% at 15% 12%, oklch(0.45 0.16 145 / 0.7), transparent 60%), + radial-gradient(ellipse 60% 50% at 85% 18%, oklch(0.45 0.16 180 / 0.65), transparent 55%), + radial-gradient(ellipse 80% 60% at 90% 88%, oklch(0.42 0.16 120 / 0.6), transparent 60%), + radial-gradient(ellipse 60% 50% at 10% 90%, oklch(0.45 0.14 95 / 0.55), transparent 55%), + linear-gradient(180deg, oklch(0.16 0.04 155), oklch(0.13 0.05 170)); + } + html.dark body[data-bg="midnight"], + html.dark .bg-variant-midnight { + background-image: + radial-gradient(ellipse 70% 60% at 18% 12%, oklch(0.4 0.22 270 / 0.85), transparent 60%), + radial-gradient(ellipse 65% 55% at 85% 20%, oklch(0.38 0.24 310 / 0.8), transparent 55%), + radial-gradient(ellipse 80% 60% at 90% 85%, oklch(0.36 0.24 240 / 0.75), transparent 60%), + radial-gradient(ellipse 60% 50% at 10% 90%, oklch(0.38 0.22 330 / 0.7), transparent 55%), + linear-gradient(180deg, oklch(0.12 0.08 265), oklch(0.09 0.1 290)); + } + html.dark body[data-bg="blush"], + html.dark .bg-variant-blush { + background-image: + radial-gradient(ellipse 70% 60% at 15% 15%, oklch(0.5 0.16 10 / 0.7), transparent 60%), + radial-gradient(ellipse 60% 50% at 85% 15%, oklch(0.48 0.16 340 / 0.65), transparent 55%), + radial-gradient(ellipse 80% 60% at 88% 88%, oklch(0.45 0.16 300 / 0.6), transparent 60%), + radial-gradient(ellipse 60% 50% at 12% 88%, oklch(0.5 0.14 45 / 0.55), transparent 55%), + linear-gradient(180deg, oklch(0.18 0.05 20), oklch(0.14 0.06 355)); + } + html.dark body[data-bg="noir"], + html.dark .bg-variant-noir { + background-image: + radial-gradient(ellipse 70% 60% at 15% 10%, oklch(0.3 0.01 260 / 0.9), transparent 60%), + radial-gradient(ellipse 60% 50% at 85% 20%, oklch(0.28 0.01 280 / 0.85), transparent 55%), + radial-gradient(ellipse 80% 60% at 90% 85%, oklch(0.26 0.01 240 / 0.8), transparent 60%), + radial-gradient(ellipse 60% 50% at 10% 90%, oklch(0.28 0.01 200 / 0.75), transparent 55%), + linear-gradient(180deg, oklch(0.12 0.005 260), oklch(0.08 0.005 260)); + } } \ No newline at end of file diff --git a/app/components/assistant/message-body.tsx b/app/components/assistant/message-body.tsx index 4a2c9a3..8e9540c 100644 --- a/app/components/assistant/message-body.tsx +++ b/app/components/assistant/message-body.tsx @@ -56,7 +56,7 @@ export function MessageBody({ content }: { content: string }) { )} {actionCount > 0 && ( diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index 5bbbfd5..90a1a4d 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -35,7 +35,11 @@ import { AppbarTitle, } from "~/components/layout/appbar" import { ThemeToggle } from "~/components/layout/theme-toggle" -import { Avatar, AvatarFallback } from "~/components/ui/avatar" +import { BackgroundPicker } from "~/components/layout/background-picker" +import { FontSizePicker } from "~/components/layout/font-size-picker" +import { SurfacePicker } from "~/components/layout/surface-picker" +import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar" +import { profileInitials, useProfile } from "~/lib/profile" import { Button } from "~/components/ui/button" import { DropdownMenu, @@ -95,8 +99,13 @@ export function AppShell({ }: AppShellProps) { const defaultBrand = useBrand() const defaultUser = useUser() + const profile = useProfile() const brand = brandOverride ?? defaultBrand - const user = userOverride ?? defaultUser + const user = userOverride ?? { + name: profile.name || defaultUser.name, + email: profile.email || defaultUser.email, + initials: profileInitials(profile.name || defaultUser.name), + } const [expanded, setExpanded] = useState(() => { if (typeof window === "undefined") return false return localStorage.getItem(SIDEBAR_KEY) === "1" @@ -267,6 +276,9 @@ export function AppShell({ > + + + - - + + + {profile.avatarUrl ? ( + + ) : null} + {user.initials} + @@ -301,13 +313,13 @@ export function AppShell({ navigate("/settings")} + onClick={() => navigate("/profile")} > Profile navigate("/settings")} + onClick={() => navigate("/settings")} > Settings diff --git a/app/components/layout/background-picker.tsx b/app/components/layout/background-picker.tsx new file mode 100644 index 0000000..914fc3e --- /dev/null +++ b/app/components/layout/background-picker.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react" +import { Check, Palette } from "lucide-react" + +import { Button } from "~/components/ui/button" +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from "~/components/ui/popover" +import { cn } from "~/lib/utils" + +export const backgrounds = [ + { id: "none", label: "None" }, + { id: "pearl", label: "Pearl" }, + { id: "linen", label: "Linen" }, + { id: "mist", label: "Mist" }, + { id: "dawn", label: "Dawn" }, + { id: "seafoam", label: "Seafoam" }, + { id: "aurora", label: "Aurora" }, + { id: "sunset", label: "Sunset" }, + { id: "meadow", label: "Meadow" }, + { id: "midnight", label: "Midnight" }, + { id: "blush", label: "Blush" }, + { id: "noir", label: "Noir" }, +] as const + +export type BackgroundId = (typeof backgrounds)[number]["id"] + +const STORAGE_KEY = "crema-bg" +const DEFAULT_BG: BackgroundId = "none" + +function isBackgroundId(value: string | null): value is BackgroundId { + return !!value && backgrounds.some((b) => b.id === value) +} + +function applyBg(id: BackgroundId) { + if (id === "none") { + delete document.body.dataset.bg + } else { + document.body.dataset.bg = id + } +} + +export function BackgroundPicker() { + const [current, setCurrent] = useState(DEFAULT_BG) + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY) + const next = isBackgroundId(stored) ? stored : DEFAULT_BG + setCurrent(next) + applyBg(next) + }, []) + + const select = (id: BackgroundId) => { + setCurrent(id) + applyBg(id) + localStorage.setItem(STORAGE_KEY, id) + } + + return ( + + + + + } + /> + + + Background + Pick an atmosphere. + +
+ {backgrounds.map((bg) => { + const active = current === bg.id + return ( + + ) + })} +
+
+
+ ) +} diff --git a/app/components/layout/font-size-picker.tsx b/app/components/layout/font-size-picker.tsx new file mode 100644 index 0000000..4f8277f --- /dev/null +++ b/app/components/layout/font-size-picker.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from "react" +import { Check, Type } from "lucide-react" + +import { Button } from "~/components/ui/button" +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from "~/components/ui/popover" +import { cn } from "~/lib/utils" + +export const fontSizes = [ + { id: "sm", label: "Small", sample: "Aa" }, + { id: "md", label: "Medium", sample: "Aa" }, + { id: "lg", label: "Large", sample: "Aa" }, + { id: "xl", label: "Extra large", sample: "Aa" }, +] as const + +export type FontSizeId = (typeof fontSizes)[number]["id"] + +const STORAGE_KEY = "crema-font-scale" +const DEFAULT_SIZE: FontSizeId = "md" + +function isFontSizeId(value: string | null): value is FontSizeId { + return !!value && fontSizes.some((f) => f.id === value) +} + +const SAMPLE_PX: Record = { + sm: 14, + md: 16, + lg: 18, + xl: 20, +} + +export function FontSizePicker() { + const [current, setCurrent] = useState(DEFAULT_SIZE) + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY) + const next = isFontSizeId(stored) ? stored : DEFAULT_SIZE + setCurrent(next) + document.documentElement.dataset.fontScale = next + }, []) + + const select = (id: FontSizeId) => { + setCurrent(id) + document.documentElement.dataset.fontScale = id + localStorage.setItem(STORAGE_KEY, id) + } + + return ( + + + + + } + /> + + + Font size + Scales the entire UI. + +
+ {fontSizes.map((f) => { + const active = current === f.id + return ( + + ) + })} +
+
+
+ ) +} diff --git a/app/components/layout/surface-picker.tsx b/app/components/layout/surface-picker.tsx new file mode 100644 index 0000000..d51bfd1 --- /dev/null +++ b/app/components/layout/surface-picker.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from "react" +import { Check, Layers } from "lucide-react" + +import { Button } from "~/components/ui/button" +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from "~/components/ui/popover" +import { cn } from "~/lib/utils" + +export const surfaces = [ + { id: "default", label: "Default" }, + { id: "snow", label: "Snow" }, + { id: "stone", label: "Stone" }, + { id: "sage", label: "Sage" }, + { id: "slate", label: "Slate" }, +] as const + +export type SurfaceId = (typeof surfaces)[number]["id"] + +const STORAGE_KEY = "crema-surface" +const DEFAULT_SURFACE: SurfaceId = "default" + +function isSurfaceId(value: string | null): value is SurfaceId { + return !!value && surfaces.some((s) => s.id === value) +} + +function applySurface(id: SurfaceId) { + if (id === "default") { + delete document.body.dataset.surface + } else { + document.body.dataset.surface = id + } +} + +export function SurfacePicker() { + const [current, setCurrent] = useState(DEFAULT_SURFACE) + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY) + const next = isSurfaceId(stored) ? stored : DEFAULT_SURFACE + setCurrent(next) + applySurface(next) + }, []) + + const select = (id: SurfaceId) => { + setCurrent(id) + applySurface(id) + localStorage.setItem(STORAGE_KEY, id) + } + + return ( + + + + + } + /> + + + Surface + Tint of cards and the sidebar. + +
+ {surfaces.map((s) => { + const active = current === s.id + return ( + + ) + })} +
+
+
+ ) +} diff --git a/app/lib/agents.ts b/app/lib/agents.ts new file mode 100644 index 0000000..9a058f0 --- /dev/null +++ b/app/lib/agents.ts @@ -0,0 +1,153 @@ +// Agent personas — named, role-scoped sub-system prompts. +// Each persona stacks on top of the main systemPrompt to specialize the +// assistant for a task. Persisted in localStorage; reactive across tabs. + +import { useEffect, useSyncExternalStore } from "react" + +export type Agent = { + id: string + name: string + role: string + prompt: string +} + +export const DEFAULT_AGENTS: Agent[] = [ + { + id: "generalist", + name: "Atlas", + role: "Generalist", + prompt: + "You handle anything: chat, planning, summaries, casual questions. Match the user's tone. Keep replies as long as the task deserves — terse for quick questions, detailed when explaining.", + }, + { + id: "coder", + name: "Forge", + role: "Software engineer", + prompt: + "You are a senior software engineer. Write idiomatic, well-typed code. Prefer concrete examples over abstract advice. When asked to fix a bug, identify root cause before patching. Use markdown code blocks with language tags. Mention edge cases briefly when relevant.", + }, + { + id: "writer", + name: "Inkwell", + role: "Writer", + prompt: + "You are a prose writer. Produce vivid, well-paced text — short stories, copy, emails, essays. Vary sentence length. Show, don't tell. When the user asks for a draft, deliver the draft, not a description of it.", + }, + { + id: "researcher", + name: "Pilot", + role: "Researcher", + prompt: + "You are a careful researcher. Structure answers as: claim → evidence → caveat. Distinguish what is well-established from what is uncertain. Refuse to fabricate citations — if you don't know, say so.", + }, + { + id: "ui-driver", + name: "Cursor", + role: "UI Operator", + prompt: + "You specialize in driving this app's UI on the user's behalf. Prefer doing over explaining. When the user asks for an action, emit an action block immediately. When they ask a question about the app, answer concisely and offer to do it.", + }, +] + +const STORAGE_KEY = "crema.agents" +const ACTIVE_KEY = "crema.assistant.activeAgent" +const CHANGE_EVENT = "crema:agents-change" + +function isAgent(v: unknown): v is Agent { + return ( + !!v && + typeof v === "object" && + typeof (v as Agent).id === "string" && + typeof (v as Agent).name === "string" && + typeof (v as Agent).role === "string" && + typeof (v as Agent).prompt === "string" + ) +} + +function readFromStorage(): Agent[] { + if (typeof window === "undefined") return DEFAULT_AGENTS + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return DEFAULT_AGENTS + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return DEFAULT_AGENTS + const cleaned = parsed.filter(isAgent) + return cleaned.length > 0 ? cleaned : DEFAULT_AGENTS + } catch { + return DEFAULT_AGENTS + } +} + +export function loadAgents(): Agent[] { + return readFromStorage() +} + +export function saveAgents(next: Agent[]) { + if (typeof window === "undefined") return + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) +} + +export function resetAgents() { + saveAgents(DEFAULT_AGENTS) +} + +let cached: Agent[] | null = null + +function subscribe(cb: () => void): () => void { + const onChange = () => { + cached = null + cb() + } + window.addEventListener(CHANGE_EVENT, onChange) + window.addEventListener("storage", (e) => { + if (e.key === STORAGE_KEY || e.key === ACTIVE_KEY) onChange() + }) + return () => { + window.removeEventListener(CHANGE_EVENT, onChange) + } +} + +function getSnapshot(): Agent[] { + if (!cached) cached = readFromStorage() + return cached +} + +function getServerSnapshot(): Agent[] { + return DEFAULT_AGENTS +} + +export function useAgents(): Agent[] { + const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + useEffect(() => { + cached = null + }, []) + return value +} + +export function loadActiveAgentId(): string { + if (typeof window === "undefined") return DEFAULT_AGENTS[0].id + try { + return localStorage.getItem(ACTIVE_KEY) ?? DEFAULT_AGENTS[0].id + } catch { + return DEFAULT_AGENTS[0].id + } +} + +export function saveActiveAgentId(id: string) { + if (typeof window === "undefined") return + localStorage.setItem(ACTIVE_KEY, id) + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) +} + +export function composeSystemPrompt( + base: string, + agent: Agent | undefined, +): string { + if (!agent) return base + return `${base}\n\nActive persona: ${agent.name} — ${agent.role}\n${agent.prompt}` +} + +export function newAgentId(): string { + return `agent-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` +} diff --git a/app/lib/library.ts b/app/lib/library.ts new file mode 100644 index 0000000..56a3cc7 --- /dev/null +++ b/app/lib/library.ts @@ -0,0 +1,125 @@ +// Library — saved artifacts. Today: conversation snapshots. +// Tomorrow: snippets, prompts, generated documents. + +import { useEffect, useSyncExternalStore } from "react" + +export type LibraryItem = { + id: string + kind: "conversation" | "snippet" + title: string + // Free-form body. For "conversation": markdown transcript. For "snippet": text. + content: string + tags: string[] + // Optional metadata. + agentName?: string + agentRole?: string + threadId?: string + messageCount?: number + createdAt: number +} + +const STORAGE_KEY = "crema.library" +const CHANGE_EVENT = "crema:library-change" +const MAX_BYTES = 1_500_000 + +export function newLibraryId(): string { + return `lib-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` +} + +function isLibraryItem(v: unknown): v is LibraryItem { + if (!v || typeof v !== "object") return false + const x = v as LibraryItem + return ( + typeof x.id === "string" && + (x.kind === "conversation" || x.kind === "snippet") && + typeof x.title === "string" && + typeof x.content === "string" && + Array.isArray(x.tags) && + typeof x.createdAt === "number" + ) +} + +function readFromStorage(): LibraryItem[] { + if (typeof window === "undefined") return [] + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter(isLibraryItem) + } catch { + return [] + } +} + +function writeToStorage(items: LibraryItem[]) { + if (typeof window === "undefined") return + let trimmed = items + let serialized = JSON.stringify(trimmed) + while (serialized.length > MAX_BYTES && trimmed.length > 1) { + trimmed = trimmed.slice(0, -1) + serialized = JSON.stringify(trimmed) + } + try { + localStorage.setItem(STORAGE_KEY, serialized) + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) + } catch { + /* quota — bail */ + } +} + +export function loadLibrary(): LibraryItem[] { + return readFromStorage() +} + +export function addLibraryItem(item: Omit): LibraryItem { + const next: LibraryItem = { + ...item, + id: newLibraryId(), + createdAt: Date.now(), + } + const items = readFromStorage() + writeToStorage([next, ...items]) + return next +} + +export function deleteLibraryItem(id: string) { + const items = readFromStorage().filter((x) => x.id !== id) + writeToStorage(items) +} + +export function updateLibraryItem(id: string, patch: Partial) { + const items = readFromStorage().map((x) => + x.id === id ? { ...x, ...patch } : x, + ) + writeToStorage(items) +} + +let cached: LibraryItem[] | null = null + +function subscribe(cb: () => void): () => void { + const onChange = () => { + cached = null + cb() + } + window.addEventListener(CHANGE_EVENT, onChange) + window.addEventListener("storage", (e) => { + if (e.key === STORAGE_KEY) onChange() + }) + return () => window.removeEventListener(CHANGE_EVENT, onChange) +} +function getSnapshot(): LibraryItem[] { + if (!cached) cached = readFromStorage() + return cached +} +function getServerSnapshot(): LibraryItem[] { + return [] +} + +export function useLibrary(): LibraryItem[] { + const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + useEffect(() => { + cached = null + }, []) + return value +} diff --git a/app/lib/llm-settings.ts b/app/lib/llm-settings.ts index bf7c0ed..0bf671f 100644 --- a/app/lib/llm-settings.ts +++ b/app/lib/llm-settings.ts @@ -7,12 +7,17 @@ export type LLMSettings = { baseURL: string contextTokens: number responseBudget: number + systemPrompt: string } +export const DEFAULT_SYSTEM_PROMPT = + "You are a helpful general-purpose assistant embedded in an app. Handle any request the user makes — writing, brainstorming, code, analysis, casual chat — at the length the task deserves. Use markdown when it helps. You can also drive the UI when the user toggles UI Control on." + export const DEFAULT_SETTINGS: LLMSettings = { baseURL: "http://localhost:1234/v1", contextTokens: 9000, responseBudget: 512, + systemPrompt: DEFAULT_SYSTEM_PROMPT, } const STORAGE_KEY = "crema.llm.settings" @@ -34,6 +39,10 @@ function readFromStorage(): LLMSettings { Number.isFinite(parsed.responseBudget) && (parsed.responseBudget as number) > 0 ? (parsed.responseBudget as number) : DEFAULT_SETTINGS.responseBudget, + systemPrompt: + typeof parsed.systemPrompt === "string" && parsed.systemPrompt.trim().length > 0 + ? parsed.systemPrompt + : DEFAULT_SETTINGS.systemPrompt, } } catch { return DEFAULT_SETTINGS diff --git a/app/lib/profile.ts b/app/lib/profile.ts new file mode 100644 index 0000000..6077098 --- /dev/null +++ b/app/lib/profile.ts @@ -0,0 +1,115 @@ +// User profile — name, email, title, bio, signature, default agent. +// Persisted in localStorage; reactive across tabs. + +import { useEffect, useSyncExternalStore } from "react" + +export type Profile = { + name: string + email: string + title: string + bio: string + signature: string + avatarUrl: string + defaultAgentId: string +} + +export const DEFAULT_PROFILE: Profile = { + name: "Signed-in user", + email: "user@example.com", + title: "", + bio: "", + signature: "", + avatarUrl: "", + defaultAgentId: "", +} + +const STORAGE_KEY = "crema.profile" +const CHANGE_EVENT = "crema:profile-change" + +function readFromStorage(): Profile { + if (typeof window === "undefined") return DEFAULT_PROFILE + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return DEFAULT_PROFILE + const parsed = JSON.parse(raw) as Partial + return { + name: + typeof parsed.name === "string" && parsed.name.trim().length > 0 + ? parsed.name + : DEFAULT_PROFILE.name, + email: + typeof parsed.email === "string" ? parsed.email : DEFAULT_PROFILE.email, + title: + typeof parsed.title === "string" ? parsed.title : DEFAULT_PROFILE.title, + bio: typeof parsed.bio === "string" ? parsed.bio : DEFAULT_PROFILE.bio, + signature: + typeof parsed.signature === "string" + ? parsed.signature + : DEFAULT_PROFILE.signature, + avatarUrl: + typeof parsed.avatarUrl === "string" + ? parsed.avatarUrl + : DEFAULT_PROFILE.avatarUrl, + defaultAgentId: + typeof parsed.defaultAgentId === "string" + ? parsed.defaultAgentId + : DEFAULT_PROFILE.defaultAgentId, + } + } catch { + return DEFAULT_PROFILE + } +} + +export function loadProfile(): Profile { + return readFromStorage() +} + +export function saveProfile(next: Profile) { + if (typeof window === "undefined") return + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) +} + +export function resetProfile() { + saveProfile(DEFAULT_PROFILE) +} + +export function profileInitials(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean) + if (words.length === 0) return "?" + if (words.length === 1) return words[0].slice(0, 2).toUpperCase() + return (words[0][0] + words[words.length - 1][0]).toUpperCase() +} + +let cached: Profile | null = null + +function subscribe(cb: () => void): () => void { + const onChange = () => { + cached = null + cb() + } + window.addEventListener(CHANGE_EVENT, onChange) + window.addEventListener("storage", (e) => { + if (e.key === STORAGE_KEY) onChange() + }) + return () => { + window.removeEventListener(CHANGE_EVENT, onChange) + } +} + +function getSnapshot(): Profile { + if (!cached) cached = readFromStorage() + return cached +} + +function getServerSnapshot(): Profile { + return DEFAULT_PROFILE +} + +export function useProfile(): Profile { + const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + useEffect(() => { + cached = null + }, []) + return value +} diff --git a/app/lib/threads.ts b/app/lib/threads.ts new file mode 100644 index 0000000..4065fb2 --- /dev/null +++ b/app/lib/threads.ts @@ -0,0 +1,217 @@ +// Conversation threads — multiple named chats, each with its own history, +// active agent, and pinned message indices. Persisted in localStorage. + +import { useEffect, useSyncExternalStore } from "react" + +export type ThreadMessage = { role: "user" | "assistant"; content: string } + +export type Thread = { + id: string + title: string + agentId: string + messages: ThreadMessage[] + pinned: number[] // indices into messages[] + createdAt: number + updatedAt: number +} + +const THREADS_KEY = "crema.assistant.threads" +const ACTIVE_KEY = "crema.assistant.activeThreadId" +const SNAPSHOT_KEY_PREFIX = "crema.assistant.thread.snapshot." +const CHANGE_EVENT = "crema:threads-change" +const MAX_BYTES = 800_000 + +export function newThreadId(): string { + return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}` +} + +function isThread(v: unknown): v is Thread { + if (!v || typeof v !== "object") return false + const t = v as Thread + return ( + typeof t.id === "string" && + typeof t.title === "string" && + typeof t.agentId === "string" && + Array.isArray(t.messages) && + Array.isArray(t.pinned) && + typeof t.createdAt === "number" && + typeof t.updatedAt === "number" + ) +} + +function readFromStorage(): Thread[] { + if (typeof window === "undefined") return [] + try { + const raw = localStorage.getItem(THREADS_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter(isThread) + } catch { + return [] + } +} + +function writeToStorage(threads: Thread[]) { + if (typeof window === "undefined") return + let serialized = JSON.stringify(threads) + // Trim oldest threads if quota gets tight. + let trimmed = threads + while (serialized.length > MAX_BYTES && trimmed.length > 1) { + trimmed = trimmed.slice(0, -1) + serialized = JSON.stringify(trimmed) + } + try { + localStorage.setItem(THREADS_KEY, serialized) + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) + } catch { + /* quota — bail */ + } +} + +export function loadThreads(): Thread[] { + return readFromStorage() +} + +export function saveThreads(threads: Thread[]) { + writeToStorage(threads) +} + +export function loadActiveThreadId(): string | null { + if (typeof window === "undefined") return null + return localStorage.getItem(ACTIVE_KEY) +} + +export function saveActiveThreadId(id: string | null) { + if (typeof window === "undefined") return + if (id) localStorage.setItem(ACTIVE_KEY, id) + else localStorage.removeItem(ACTIVE_KEY) + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) +} + +let cached: Thread[] | null = null + +function subscribe(cb: () => void): () => void { + const onChange = () => { + cached = null + cb() + } + window.addEventListener(CHANGE_EVENT, onChange) + window.addEventListener("storage", (e) => { + if (e.key === THREADS_KEY || e.key === ACTIVE_KEY) onChange() + }) + return () => { + window.removeEventListener(CHANGE_EVENT, onChange) + } +} + +function getSnapshot(): Thread[] { + if (!cached) cached = readFromStorage() + return cached +} +function getServerSnapshot(): Thread[] { + return [] +} + +export function useThreads(): Thread[] { + const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + useEffect(() => { + cached = null + }, []) + return value +} + +export function ensureThread( + threads: Thread[], + fallbackAgentId: string, +): { threads: Thread[]; activeId: string } { + const stored = loadActiveThreadId() + if (stored && threads.some((t) => t.id === stored)) + return { threads, activeId: stored } + if (threads.length > 0) { + saveActiveThreadId(threads[0].id) + return { threads, activeId: threads[0].id } + } + const id = newThreadId() + const now = Date.now() + const fresh: Thread = { + id, + title: "New conversation", + agentId: fallbackAgentId, + messages: [], + pinned: [], + createdAt: now, + updatedAt: now, + } + saveActiveThreadId(id) + saveThreads([fresh, ...threads]) + return { threads: [fresh, ...threads], activeId: id } +} + +export function updateThread(id: string, patch: Partial) { + const threads = readFromStorage() + const next = threads.map((t) => + t.id === id ? { ...t, ...patch, updatedAt: Date.now() } : t, + ) + writeToStorage(next) +} + +export function createThread(agentId: string, title = "New conversation"): Thread { + const threads = readFromStorage() + const id = newThreadId() + const now = Date.now() + const fresh: Thread = { + id, + title, + agentId, + messages: [], + pinned: [], + createdAt: now, + updatedAt: now, + } + writeToStorage([fresh, ...threads]) + saveActiveThreadId(id) + return fresh +} + +export function deleteThread(id: string) { + const threads = readFromStorage() + const next = threads.filter((t) => t.id !== id) + writeToStorage(next) + if (loadActiveThreadId() === id) { + saveActiveThreadId(next[0]?.id ?? null) + } +} + +export function snapshotThread(id: string) { + const threads = readFromStorage() + const t = threads.find((x) => x.id === id) + if (!t) return + try { + localStorage.setItem(SNAPSHOT_KEY_PREFIX + id, JSON.stringify(t)) + } catch { + /* quota */ + } +} + +export function loadThreadSnapshot(id: string): Thread | null { + if (typeof window === "undefined") return null + try { + const raw = localStorage.getItem(SNAPSHOT_KEY_PREFIX + id) + if (!raw) return null + const parsed = JSON.parse(raw) + return isThread(parsed) ? parsed : null + } catch { + return null + } +} + +export function clearThreadSnapshot(id: string) { + if (typeof window === "undefined") return + localStorage.removeItem(SNAPSHOT_KEY_PREFIX + id) +} + +export function deriveTitleFromFirstMessage(text: string): string { + const trimmed = text.trim().split(/\s+/).slice(0, 8).join(" ") + return trimmed.length > 60 ? trimmed.slice(0, 57) + "…" : trimmed || "New conversation" +} diff --git a/app/root.tsx b/app/root.tsx index 83036d0..6d04b0e 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -16,7 +16,7 @@ import { CommandBusProvider } from "@crema/action-bus" export function Layout({ children }: { children: React.ReactNode }) { return ( - + @@ -24,11 +24,11 @@ export function Layout({ children }: { children: React.ReactNode }) {