Files
crema-app-aifirst-template/app/components/layout/app-shell.tsx
jules eea5b262cb feat: appbar pickers, multi-agent personas, threads, library, profile
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) <noreply@anthropic.com>
2026-04-28 14:29:58 +10:00

351 lines
12 KiB
TypeScript

import { useEffect, useState } from "react"
const SIDEBAR_KEY = "crema.shell.sidebar"
import { NavLink, useNavigate } from "react-router"
import {
Bell,
LayoutDashboard,
Boxes,
Activity,
Sparkles,
BookOpen,
Settings,
Search,
PanelLeftClose,
PanelLeftOpen,
User as UserIcon,
LogOut,
HelpCircle,
Menu,
Play,
// CREMA:NAV-ICONS
} from "lucide-react"
import {
useBrand,
useUser,
type Brand,
type User,
} from "~/lib/identity"
import {
Appbar,
AppbarActions,
AppbarSpacer,
AppbarTitle,
} from "~/components/layout/appbar"
import { ThemeToggle } from "~/components/layout/theme-toggle"
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,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import { Input } from "~/components/ui/input"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "~/components/ui/sheet"
import { ScriptsDialog, useScriptsHotkey } from "~/components/scripts-dialog"
type NavItem = {
to: string
icon: React.ComponentType<{ className?: string }>
label: string
end?: boolean
}
const navItems: NavItem[] = [
{ to: "/", icon: LayoutDashboard, label: "Overview", end: true },
{ to: "/resources", icon: Boxes, label: "Resources" },
{ to: "/activity", icon: Activity, label: "Activity" },
{ to: "/assistant", icon: Sparkles, label: "Assistant" },
{ to: "/library", icon: BookOpen, label: "Library" },
{ to: "/settings", icon: Settings, label: "Settings" },
// CREMA:NAV-ITEMS
]
type AppShellProps = {
title: string
children: React.ReactNode
brand?: Brand
user?: User
/**
* Optional theme name. When set, the shell wraps itself in
* `data-theme={theme}` so a route can opt into an alternate theme without
* the caller having to add an extra wrapping div.
*/
theme?: string
}
export function AppShell({
title,
children,
brand: brandOverride,
user: userOverride,
theme,
}: AppShellProps) {
const defaultBrand = useBrand()
const defaultUser = useUser()
const profile = useProfile()
const brand = brandOverride ?? defaultBrand
const user = userOverride ?? {
name: profile.name || defaultUser.name,
email: profile.email || defaultUser.email,
initials: profileInitials(profile.name || defaultUser.name),
}
const [expanded, setExpanded] = useState<boolean>(() => {
if (typeof window === "undefined") return false
return localStorage.getItem(SIDEBAR_KEY) === "1"
})
useEffect(() => {
localStorage.setItem(SIDEBAR_KEY, expanded ? "1" : "0")
}, [expanded])
const [mobileOpen, setMobileOpen] = useState(false)
const [scriptsOpen, setScriptsOpen] = useState(false)
const navigate = useNavigate()
const BrandIcon = brand.icon
useScriptsHotkey(() => setScriptsOpen(true))
return (
<div
data-theme={theme}
className="relative isolate flex min-h-svh"
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-2 focus:top-2 focus:z-50 focus:rounded-md focus:bg-primary focus:px-3 focus:py-2 focus:text-primary-foreground focus:shadow-lg"
>
Skip to main content
</a>
<aside
data-slot="sidebar"
data-expanded={expanded ? "true" : "false"}
className={[
"sticky top-0 z-30 hidden h-svh shrink-0 flex-col border-r bg-sidebar transition-[width] duration-base ease-standard md:flex",
expanded ? "w-60" : "w-16",
].join(" ")}
>
<div
className={[
"flex h-14 items-center gap-2 border-b px-3",
expanded ? "justify-between" : "justify-center",
].join(" ")}
>
{expanded ? (
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<BrandIcon className="size-4" />
</div>
<span className="font-heading font-semibold tracking-tight">
{brand.name}
</span>
</div>
) : (
<div className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<BrandIcon className="size-4" />
</div>
)}
</div>
<nav className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
{navItems.map((item) => {
const Icon = item.icon
return (
<NavLink
key={item.label}
to={item.to}
end={item.end}
title={expanded ? undefined : item.label}
data-action={`nav-${item.label.toLowerCase()}`}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-lg px-3 py-2 font-medium transition-colors duration-fast ease-standard",
expanded ? "justify-start" : "justify-center",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
].join(" ")
}
>
<Icon className="size-5 shrink-0" />
{expanded && <span className="truncate">{item.label}</span>}
</NavLink>
)
})}
</nav>
<div className="shrink-0 border-t p-2">
<Button
data-action="sidebar-toggle"
variant="ghost"
size={expanded ? "sm" : "icon-sm"}
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "Collapse sidebar" : "Expand sidebar"}
className={expanded ? "w-full justify-start" : "w-full"}
>
{expanded ? (
<>
<PanelLeftClose className="size-4" />
<span>Collapse</span>
</>
) : (
<PanelLeftOpen className="size-4" />
)}
</Button>
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
<Appbar className="sticky top-0 z-20 border-b">
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger
data-action="mobile-nav-toggle"
aria-label="Open navigation"
className="mr-1 inline-flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground md:hidden"
>
<Menu className="size-5" />
</SheetTrigger>
<SheetContent side="left" className="w-72 p-0">
<SheetHeader className="border-b">
<SheetTitle className="flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<BrandIcon className="size-4" />
</div>
{brand.name}
</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-1 p-2">
{navItems.map((item) => {
const Icon = item.icon
return (
<NavLink
key={item.label}
to={item.to}
end={item.end}
onClick={() => setMobileOpen(false)}
data-action={`nav-mobile-${item.label.toLowerCase()}`}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-lg px-3 py-2 font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
].join(" ")
}
>
<Icon className="size-5 shrink-0" />
<span>{item.label}</span>
</NavLink>
)
})}
</nav>
</SheetContent>
</Sheet>
<AppbarTitle>{title}</AppbarTitle>
<div className="relative ml-6 hidden md:block">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
data-action="appbar-search"
placeholder="Search…"
className="h-9 w-80 pl-8"
/>
</div>
<AppbarSpacer />
<AppbarActions>
<Button
data-action="appbar-scripts"
variant="ghost"
size="icon-sm"
aria-label="Run script (Cmd+Shift+P)"
title="Run script (Cmd+Shift+P)"
onClick={() => setScriptsOpen(true)}
>
<Play />
</Button>
<FontSizePicker />
<SurfacePicker />
<BackgroundPicker />
<ThemeToggle />
<Button
data-action="appbar-notifications"
variant="ghost"
size="icon-sm"
aria-label="Notifications"
>
<Bell />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
data-action="appbar-avatar"
aria-label="Account menu"
className="rounded-full outline-none ring-offset-2 ring-offset-background transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring"
>
<Avatar className="size-8 cursor-pointer">
{profile.avatarUrl ? (
<AvatarImage src={profile.avatarUrl} alt={user.name} />
) : null}
<AvatarFallback>{user.initials}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel className="flex flex-col gap-0.5">
<span className="font-medium">{user.name}</span>
<span className="text-xs font-normal text-muted-foreground">
{user.email}
</span>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
data-action="avatar-profile"
onClick={() => navigate("/profile")}
>
<UserIcon /> Profile
</DropdownMenuItem>
<DropdownMenuItem
data-action="avatar-settings"
onClick={() => navigate("/settings")}
>
<Settings /> Settings
</DropdownMenuItem>
<DropdownMenuItem data-action="avatar-help">
<HelpCircle /> Help
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem data-action="avatar-signout" variant="destructive">
<LogOut /> Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</AppbarActions>
</Appbar>
<div
id="main-content"
tabIndex={-1}
className="flex flex-1 flex-col gap-6 p-6 focus:outline-none"
>
{children}
</div>
</main>
<ScriptsDialog open={scriptsOpen} onOpenChange={setScriptsOpen} />
</div>
)
}