fix+upstream: hooks-order crash, ConfirmDialog/PageHeader/skeletons, styled error boundary

Hoists hooks above early return (render-time Navigate); ports finance's
ConfirmDialog/PageHeader/loading/states + APC error-copy mapper; removes
window.confirm, dead appbar search, and seeded fake notifications; wires toasts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jules
2026-07-04 13:46:12 +10:00
parent 3dbf2ac175
commit 675f6f8b35
13 changed files with 809 additions and 319 deletions

View File

@@ -0,0 +1,99 @@
import { useState, type ReactElement, type ReactNode } from "react"
import { Loader2 } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog"
type Props = {
/** The element that opens the dialog — a Button or bare button. */
trigger: ReactElement
title: string
description?: ReactNode
confirmLabel?: string
destructive?: boolean
onConfirm: () => Promise<void> | void
}
/** Branded confirm dialog. Replaces `window.confirm` — branded buttons,
* keyboard-trapped, dismisses on Esc, awaits async confirm handlers
* so we can show a spinner while the operation runs. */
export function ConfirmDialog({
trigger,
title,
description,
confirmLabel = "Confirm",
destructive,
onConfirm,
}: Props) {
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleConfirm() {
setBusy(true)
setError(null)
try {
await onConfirm()
setOpen(false)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
return (
<AlertDialog
open={open}
onOpenChange={(v) => {
if (!busy) setOpen(v)
}}
>
<AlertDialogTrigger render={trigger} />
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? (
<AlertDialogDescription>{description}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<AlertDialogFooter>
<AlertDialogCancel data-action="confirm-cancel" disabled={busy}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
data-action="confirm-accept"
onClick={(e) => {
e.preventDefault()
void handleConfirm()
}}
disabled={busy}
className={
destructive
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: undefined
}
>
{busy ? (
<>
<Loader2 className="animate-spin" /> Working
</>
) : (
confirmLabel
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
const SIDEBAR_KEY = "crema.shell.sidebar" const SIDEBAR_KEY = "crema.shell.sidebar"
import { NavLink, useNavigate } from "react-router" import { NavLink, Navigate, useNavigate } from "react-router"
import { import {
Bell, Bell,
LayoutDashboard, LayoutDashboard,
@@ -52,7 +52,6 @@ import {
dismissAll, dismissAll,
markAllRead, markAllRead,
markRead, markRead,
seedIfEmpty,
unreadCount, unreadCount,
useNotifications, useNotifications,
} from "~/lib/notifications" } from "~/lib/notifications"
@@ -66,7 +65,6 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu" } from "~/components/ui/dropdown-menu"
import { Input } from "~/components/ui/input"
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@@ -129,17 +127,10 @@ export function AppShell({
), ),
} }
// Protected shell: bounce to /login when there's no session. // All hooks must be called unconditionally — declare them BEFORE any
useEffect(() => { // early return so React's render-time hook count is stable. (Previously
if (typeof window === "undefined") return // `if (!session) return null` sat above these hooks, so the hook count
if (!session) { // changed the moment the session flipped → hook-order crash.)
const next = encodeURIComponent(
window.location.pathname + window.location.search,
)
navigate(`/login?next=${next}`, { replace: true })
}
}, [session, navigate])
if (!session) return null
const [expanded, setExpanded] = useState<boolean>(() => { const [expanded, setExpanded] = useState<boolean>(() => {
if (typeof window === "undefined") return false if (typeof window === "undefined") return false
return localStorage.getItem(SIDEBAR_KEY) === "1" return localStorage.getItem(SIDEBAR_KEY) === "1"
@@ -149,10 +140,20 @@ export function AppShell({
}, [expanded]) }, [expanded])
const [mobileOpen, setMobileOpen] = useState(false) const [mobileOpen, setMobileOpen] = useState(false)
const [scriptsOpen, setScriptsOpen] = useState(false) const [scriptsOpen, setScriptsOpen] = useState(false)
const BrandIcon = brand.icon
useScriptsHotkey(() => setScriptsOpen(true)) useScriptsHotkey(() => setScriptsOpen(true))
// Protected shell: redirect to /login when there's no session. Done at
// render time (not in an effect) so we don't briefly render a blank
// shell during the redirect window.
if (!session) {
const next =
typeof window !== "undefined"
? encodeURIComponent(window.location.pathname + window.location.search)
: ""
return <Navigate to={`/login?next=${next}`} replace />
}
const BrandIcon = brand.icon
return ( return (
<div <div
data-theme={theme} data-theme={theme}
@@ -289,14 +290,20 @@ export function AppShell({
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<AppbarTitle>{title}</AppbarTitle> <AppbarTitle>{title}</AppbarTitle>
<div className="relative ml-6 hidden md:block"> {/* Honest search affordance: the template has no search index, so
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" /> this routes to the Assistant — the app's real natural-language
<Input surface — rather than being a dead <input>. Forks with a search
data-action="appbar-search" route should point this at it (and add a ⌘K palette hint). */}
placeholder="Search…" <button
className="h-9 w-80 pl-8" type="button"
/> data-action="appbar-search"
</div> onClick={() => navigate("/assistant")}
className="ml-6 hidden h-9 w-80 items-center gap-2 rounded-md border bg-background px-2.5 text-left text-sm text-muted-foreground transition hover:bg-accent/40 md:flex"
title="Ask the assistant — it can answer questions and drive the app."
>
<Search className="size-4" />
<span className="flex-1 truncate">Ask the assistant</span>
</button>
<AppbarSpacer /> <AppbarSpacer />
<AppbarActions> <AppbarActions>
<Button <Button
@@ -459,10 +466,6 @@ function NotificationsBell() {
const unread = unreadCount(items) const unread = unreadCount(items)
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => {
seedIfEmpty()
}, [])
return ( return (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger

View File

@@ -0,0 +1,36 @@
import { type ReactNode } from "react"
interface PageHeaderProps {
title: ReactNode
description?: ReactNode
/** Inline indicators after the title (badges, status pills). */
badges?: ReactNode
/** Toolbar rendered below the title row — primary actions go here. */
actions?: ReactNode
}
// Shared page header for the app's main surfaces. Keeps the title/description
// pattern consistent across routes so forks don't reinvent it per page.
export function PageHeader({
title,
description,
badges,
actions,
}: PageHeaderProps) {
return (
<header className="flex flex-col gap-3">
<div className="flex flex-wrap items-end gap-4">
<h1 className="text-headline font-semibold tracking-tight">{title}</h1>
{badges}
</div>
{description ? (
<p className="max-w-2xl text-[15px] leading-relaxed text-muted-foreground">
{description}
</p>
) : null}
{actions ? (
<div className="mt-2 flex flex-wrap items-center gap-2">{actions}</div>
) : null}
</header>
)
}

View File

@@ -0,0 +1,98 @@
import { Skeleton } from "~/components/ui/skeleton"
import { Card, CardContent } from "~/components/ui/card"
/** Layout-preserving placeholder for a list/table surface. Renders a
* header bar + `rows` row skeletons. Looks far less alarming than a
* single spinner during slow loads and keeps the layout from jumping
* when data lands. */
export function ListSkeleton({ rows = 8 }: { rows?: number }) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Skeleton className="h-9 w-72" />
<Skeleton className="h-8 w-24" />
</div>
<div className="rounded-md border">
<div className="flex items-center gap-4 border-b bg-muted/30 px-4 py-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-20" />
<Skeleton className="ml-auto h-3 w-16" />
</div>
{Array.from({ length: rows }).map((_, i) => (
<div
key={i}
className="flex items-center gap-4 border-b px-4 py-3 last:border-b-0"
>
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-16" />
<Skeleton className="ml-auto h-4 w-24" />
</div>
))}
</div>
</div>
)
}
/** Layout-preserving placeholder for a dashboard/overview surface. Mirrors
* a hero KPI + secondary KPIs, a trend + categories row, a forecast strip
* and a list card so there's no visible reshuffle when data lands. Shape
* it to match whatever your overview renders. */
export function DashboardSkeleton() {
return (
<div className="space-y-6">
{/* Hero KPI row */}
<section className="grid gap-6 lg:grid-cols-3 lg:items-end">
<div className="lg:col-span-2 space-y-3">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-14 w-72" />
<Skeleton className="h-8 w-full max-w-md" />
</div>
<div className="space-y-4">
<Skeleton className="h-7 w-full" />
<Skeleton className="h-7 w-full" />
<Skeleton className="h-3 w-32" />
</div>
</section>
{/* Trend + categories */}
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardContent className="space-y-3 p-5 md:p-7">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-64" />
<Skeleton className="h-56 w-full" />
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4 p-5 md:p-7">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-8 w-40" />
<Skeleton className="h-2.5 w-full rounded-full" />
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
{/* Forecast */}
<Card>
<CardContent className="space-y-4 p-5 md:p-7">
<Skeleton className="h-4 w-32" />
<div className="grid gap-4 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-7 w-32" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}

114
app/components/states.tsx Normal file
View File

@@ -0,0 +1,114 @@
import type { ReactNode } from "react"
import { AlertTriangle } from "lucide-react"
import { Card, CardContent, CardHeader } from "~/components/ui/card"
import { Skeleton } from "~/components/ui/skeleton"
type EmptyStateProps = {
icon?: ReactNode
title: string
description?: ReactNode
action?: ReactNode
secondaryAction?: ReactNode
/** When true, wrap the body in a bordered card frame. Default false —
* a bare dashed surface that reads as "nothing here yet". */
framed?: boolean
}
/** Shared empty state. Centred icon disc + title + description + optional
* actions, using theme tokens only. Pair with EmptyState → ErrorState →
* content branching so a route never renders a bare blank surface. */
export function EmptyState({
icon,
title,
description,
action,
secondaryAction,
framed = false,
}: EmptyStateProps) {
const body = (
<div className="flex flex-col items-center gap-5 px-8 py-16 text-center">
{icon ? (
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
{icon}
</div>
) : null}
<div className="space-y-2">
<p className="text-title font-semibold tracking-tight">{title}</p>
{description ? (
<p className="mx-auto max-w-md text-[15px] leading-relaxed text-muted-foreground">
{description}
</p>
) : null}
</div>
{action || secondaryAction ? (
<div className="mt-1 flex flex-wrap items-center justify-center gap-2">
{action}
{secondaryAction}
</div>
) : null}
</div>
)
if (framed) {
return (
<Card>
<CardContent className="p-0">{body}</CardContent>
</Card>
)
}
return (
<div className="overflow-hidden rounded-2xl border-2 border-dashed border-muted-foreground/20 bg-muted/30">
{body}
</div>
)
}
/** Shared error state — a soft destructive-tinted banner. Used both by
* routes (data-fetch failures) and as the middle rung of the
* skeleton → error → empty → content grammar. */
export function ErrorState({
title = "Something went wrong",
message,
action,
}: {
title?: string
message: string
action?: ReactNode
}) {
return (
<div className="flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-destructive" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium text-destructive">{title}</p>
<p className="text-sm text-muted-foreground">{message}</p>
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
)
}
/** Card-shaped loading placeholder for card lists/grids. */
export function LoadingCards({
count = 3,
variant = "row",
}: {
count?: number
variant?: "row" | "grid"
}) {
const items = Array.from({ length: count })
return (
<div className={variant === "grid" ? "grid gap-5 sm:grid-cols-2" : "space-y-3"}>
{items.map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-2 pb-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-64" />
</CardHeader>
<CardContent>
<Skeleton className="h-3 w-3/4" />
</CardContent>
</Card>
))}
</div>
)
}

73
app/lib/errors.ts Normal file
View File

@@ -0,0 +1,73 @@
// Human error copy. Maps error codes / shapes / thrown values to a single
// plain sentence a person can act on. Used by the root ErrorBoundary and
// available to any route that catches a failure (e.g. a fetch reject).
//
// Ported/generalised from arcadia-personal-cloud-web's `acceptErrorMessage`:
// switch on a known machine `code`, else flatten field-validation errors,
// else fall back to the raw message or a friendly default.
import { isRouteErrorResponse } from "react-router"
/** A known machine error code → the human sentence we show for it. Extend
* this map as the app grows real backend error codes. */
const CODE_MESSAGES: Record<string, string> = {
network_error: "Couldn't reach the server. Check your connection and try again.",
timeout: "That took too long to respond. Try again in a moment.",
unauthorized: "Your session has expired. Sign in again to continue.",
forbidden: "You don't have access to that.",
not_found: "We couldn't find what you were looking for.",
rate_limited: "Too many requests just now. Give it a few seconds.",
server_error: "Something went wrong on our end. Try again shortly.",
}
type ErrorShape = {
error?: string
code?: string
message?: string
errors?: Record<string, string[] | string>
}
/** Turn any caught value into one human sentence. */
export function humanErrorMessage(err: unknown): string {
// React Router route-error responses (thrown Responses / 404s etc.).
if (isRouteErrorResponse(err)) {
if (err.status === 404) return CODE_MESSAGES.not_found
if (err.status === 401) return CODE_MESSAGES.unauthorized
if (err.status === 403) return CODE_MESSAGES.forbidden
if (err.status === 429) return CODE_MESSAGES.rate_limited
if (err.status >= 500) return CODE_MESSAGES.server_error
return err.statusText || "That request couldn't be completed."
}
const d = (
err && typeof err === "object" ? err : {}
) as ErrorShape
const code = d.code ?? d.error
if (code && CODE_MESSAGES[code]) return CODE_MESSAGES[code]
// arcadia-style field validation: { errors: { field: [msg...] } } —
// flatten into a single readable line so the user knows what to fix.
if (d.errors && typeof d.errors === "object") {
const lines: string[] = []
for (const [field, msgs] of Object.entries(d.errors)) {
const label = field.replace(/_/g, " ")
const arr = Array.isArray(msgs) ? msgs : [String(msgs)]
for (const m of arr) lines.push(`${label} ${m}`)
}
if (lines.length) return lines.join("; ")
}
if (err instanceof Error && err.message) return err.message
if (typeof d.message === "string" && d.message) return d.message
return "An unexpected error occurred. Please try again."
}
/** Short heading to pair with {@link humanErrorMessage} in a boundary/card. */
export function humanErrorTitle(err: unknown): string {
if (isRouteErrorResponse(err)) {
if (err.status === 404) return "Page not found"
return "Something went wrong"
}
return "Something went wrong"
}

View File

@@ -127,29 +127,3 @@ export function useNotifications(): AppNotification[] {
export function unreadCount(items: AppNotification[]): number { export function unreadCount(items: AppNotification[]): number {
return items.filter((n) => !n.readAt).length return items.filter((n) => !n.readAt).length
} }
/** Seed a few demo notifications on first load so the bell isn't empty. */
export function seedIfEmpty() {
if (typeof window === "undefined") return
if (localStorage.getItem(STORAGE_KEY)) return
const now = Date.now()
const seed: AppNotification[] = [
{
id: newId(),
kind: "info",
title: "Welcome",
body: "Tag elements with data-action and the assistant can drive them.",
href: "/assistant",
createdAt: now - 60_000,
},
{
id: newId(),
kind: "success",
title: "Profile saved",
body: "Your display name and avatar are live across the app.",
href: "/profile",
createdAt: now - 5 * 60_000,
},
]
writeToStorage(seed)
}

View File

@@ -6,10 +6,14 @@ import {
ScrollRestoration, ScrollRestoration,
isRouteErrorResponse, isRouteErrorResponse,
} from "react-router" } from "react-router"
import { AlertTriangle, Home, RotateCcw } from "lucide-react"
import type { Route } from "./+types/root" import type { Route } from "./+types/root"
import "./app.css" import "./app.css"
import { humanErrorMessage, humanErrorTitle } from "~/lib/errors"
import { Button, buttonVariants } from "~/components/ui/button"
import { ToastProvider } from "@crema/notification-ui" import { ToastProvider } from "@crema/notification-ui"
import { CommandBusProvider } from "@crema/action-bus" import { CommandBusProvider } from "@crema/action-bus"
// CREMA:PROVIDERS-IMPORTS // CREMA:PROVIDERS-IMPORTS
@@ -50,30 +54,48 @@ export default function App() {
} }
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!" const title = humanErrorTitle(error)
let details = "An unexpected error occurred." const message = humanErrorMessage(error)
let stack: string | undefined const is404 = isRouteErrorResponse(error) && error.status === 404
// Stack traces are a developer affordance — never leak them to users in
if (isRouteErrorResponse(error)) { // production, and even in dev keep them tucked below the human copy.
message = error.status === 404 ? "404" : "Error" const stack =
details = import.meta.env.DEV && error instanceof Error ? error.stack : undefined
error.status === 404
? "The requested page could not be found."
: error.statusText || details
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message
stack = error.stack
}
return ( return (
<main className="container mx-auto p-4 pt-16"> <main className="flex min-h-svh items-center justify-center bg-background p-6">
<h1>{message}</h1> <div className="w-full max-w-md rounded-2xl border bg-card p-8 text-center shadow-e1">
<p>{details}</p> <div className="mx-auto mb-5 flex size-12 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
{stack && ( <AlertTriangle className="size-6" />
<pre className="w-full overflow-x-auto p-4"> </div>
<code>{stack}</code> <h1 className="text-title font-semibold tracking-tight text-card-foreground">
</pre> {is404 ? "Page not found" : title}
)} </h1>
<p className="mx-auto mt-2 max-w-sm text-[15px] leading-relaxed text-muted-foreground">
{message}
</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
<a
href="/"
data-action="error-go-home"
className={buttonVariants({ variant: "default" })}
>
<Home className="size-4" /> Go home
</a>
<Button
data-action="error-retry"
variant="outline"
onClick={() => window.location.reload()}
>
<RotateCcw className="size-4" /> Try again
</Button>
</div>
{stack ? (
<pre className="mt-6 max-h-64 overflow-auto rounded-lg border bg-muted/40 p-4 text-left font-mono text-xs leading-relaxed text-muted-foreground">
<code>{stack}</code>
</pre>
) : null}
</div>
</main> </main>
) )
} }

View File

@@ -1,13 +1,8 @@
import { Activity } from "lucide-react" import { Activity } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell" import { AppShell } from "~/components/layout/app-shell"
import { import { PageHeader } from "~/components/layout/page-header"
Card, import { EmptyState } from "~/components/states"
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { pageTitle } from "~/lib/page-meta" import { pageTitle } from "~/lib/page-meta"
export const meta = () => pageTitle("Activity") export const meta = () => pageTitle("Activity")
@@ -15,29 +10,21 @@ export const meta = () => pageTitle("Activity")
export default function ActivityRoute() { export default function ActivityRoute() {
return ( return (
<AppShell title="Activity"> <AppShell title="Activity">
<Card> <PageHeader
<CardHeader> title="Activity"
<CardTitle>Activity</CardTitle> description="Event stream, audit log, recent changes."
<CardDescription> />
Event stream, audit log, recent changes. <EmptyState
</CardDescription> icon={<Activity className="size-6" />}
</CardHeader> title="No activity yet"
<CardContent> description={
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/30 p-12 text-center"> <>
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground"> Once your app is doing things, this is where audit events, webhook
<Activity className="size-6" /> deliveries, and recent changes show up pair with{" "}
</div> <code className="font-mono text-xs">@crema/log-ui</code>.
<div className="max-w-md"> </>
<p className="font-medium">No activity yet</p> }
<p className="mt-1 text-sm text-muted-foreground"> />
Once your app is doing things, this is where audit events,
webhook deliveries, and recent changes show up pair with{" "}
<code className="font-mono text-xs">@crema/log-ui</code>.
</p>
</div>
</div>
</CardContent>
</Card>
</AppShell> </AppShell>
) )
} }

View File

@@ -111,6 +111,7 @@ import { TypingIndicator } from "@crema/chat-ui"
import { CommandBar } from "@crema/aifirst-ui" import { CommandBar } from "@crema/aifirst-ui"
import { AppShell } from "~/components/layout/app-shell" import { AppShell } from "~/components/layout/app-shell"
import { ConfirmDialog } from "~/components/confirm-dialog"
import { MessageBody } from "~/components/assistant/message-body" import { MessageBody } from "~/components/assistant/message-body"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { import {
@@ -1956,20 +1957,32 @@ function ThreadsPicker({
> >
<Pencil className="size-3.5" /> <Pencil className="size-3.5" />
</button> </button>
<button <ConfirmDialog
type="button" trigger={
data-action={`assistant-thread-delete-${t.id}`} <button
onClick={() => { type="button"
data-action={`assistant-thread-delete-${t.id}`}
disabled={threads.length <= 1}
className="rounded p-1 text-muted-foreground opacity-0 hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30"
title={
threads.length <= 1
? "Can't delete the last conversation"
: "Delete"
}
aria-label="Delete"
>
<Trash2 className="size-3.5" />
</button>
}
title="Delete conversation?"
description={`"${t.title}" and its messages will be permanently removed.`}
confirmLabel="Delete"
destructive
onConfirm={() => {
if (threads.length <= 1) return if (threads.length <= 1) return
if (window.confirm(`Delete "${t.title}"?`)) onDelete(t.id) onDelete(t.id)
}} }}
disabled={threads.length <= 1} />
className="rounded p-1 text-muted-foreground opacity-0 hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30"
title="Delete"
aria-label="Delete"
>
<Trash2 className="size-3.5" />
</button>
</div> </div>
) )
})} })}

View File

@@ -2,13 +2,8 @@ import { ArrowRight, Sparkles, Boxes, Activity, BookOpen } from "lucide-react"
import { Link } from "react-router" import { Link } from "react-router"
import { AppShell } from "~/components/layout/app-shell" import { AppShell } from "~/components/layout/app-shell"
import { import { PageHeader } from "~/components/layout/page-header"
Card, import { Card, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { pageTitle } from "~/lib/page-meta" import { pageTitle } from "~/lib/page-meta"
export const meta = () => pageTitle("Overview") export const meta = () => pageTitle("Overview")
@@ -44,19 +39,19 @@ const tiles = [
export default function HomeRoute() { export default function HomeRoute() {
return ( return (
<AppShell title="Overview"> <AppShell title="Overview">
<Card> <PageHeader
<CardHeader> title="Welcome"
<CardTitle>Welcome</CardTitle> description={
<CardDescription> <>
A hybrid traditional + AI-first scaffold. Use the rail to navigate; A hybrid traditional + AI-first scaffold. Use the rail to navigate;
the Assistant can drive the UI on your behalf try{" "} the Assistant can drive the UI on your behalf try{" "}
<kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs"> <kbd className="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs">
P P
</kbd>{" "} </kbd>{" "}
for the script runner. for the script runner.
</CardDescription> </>
</CardHeader> }
</Card> />
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{tiles.map((t) => { {tiles.map((t) => {

View File

@@ -1,15 +1,14 @@
import { useState } from "react" import { useEffect, useState } from "react"
import { BookOpen, Copy, Download, Trash2, MessagesSquare } from "lucide-react" import { BookOpen, Copy, Download, Trash2, MessagesSquare } from "lucide-react"
import { useToast } from "@crema/notification-ui"
import { AppShell } from "~/components/layout/app-shell" import { AppShell } from "~/components/layout/app-shell"
import { PageHeader } from "~/components/layout/page-header"
import { ConfirmDialog } from "~/components/confirm-dialog"
import { ListSkeleton } from "~/components/loading"
import { EmptyState, ErrorState } from "~/components/states"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { import { Card, CardContent } from "~/components/ui/card"
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Input } from "~/components/ui/input" import { Input } from "~/components/ui/input"
import { pageTitle } from "~/lib/page-meta" import { pageTitle } from "~/lib/page-meta"
import { import {
@@ -24,6 +23,15 @@ export default function LibraryRoute() {
const items = useLibrary() const items = useLibrary()
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [openId, setOpenId] = useState<string | null>(null) const [openId, setOpenId] = useState<string | null>(null)
// Load-state grammar: skeleton → error → empty → content. The store is
// synchronous localStorage, so `loading` only gates the first frame — a
// fork backing Library with an async API drives these off the request.
const [loading, setLoading] = useState(true)
const [error] = useState<string | null>(null)
useEffect(() => {
setLoading(false)
}, [])
const filtered = items.filter((it) => { const filtered = items.filter((it) => {
if (!query.trim()) return true if (!query.trim()) return true
@@ -39,25 +47,41 @@ export default function LibraryRoute() {
return ( return (
<AppShell title="Library"> <AppShell title="Library">
<Card> <PageHeader
<CardHeader> title="Library"
<CardTitle>Library</CardTitle> description={
<CardDescription> <>
Saved items and templates. Save a chat from the Assistant via the Saved items and templates. Save a chat from the Assistant via the
menu "Save to Library". menu <span className="font-medium">Save to Library</span>.
</CardDescription> </>
</CardHeader> }
<CardContent className="flex flex-col gap-4"> />
<Input
data-action="library-search" {loading ? (
placeholder="Search saved items…" <ListSkeleton />
value={query} ) : error ? (
onChange={(e) => setQuery(e.target.value)} <ErrorState message={error} />
/> ) : items.length === 0 ? (
<EmptyState
icon={<BookOpen className="size-6" />}
title="Library is empty"
description={
<>
Save a conversation from the Assistant via the menu {" "}
<span className="font-medium">Save to Library</span>.
</>
}
/>
) : (
<Card>
<CardContent className="flex flex-col gap-4 pt-6">
<Input
data-action="library-search"
placeholder="Search saved items…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{items.length === 0 ? (
<EmptyState />
) : (
<div className="grid gap-3 md:grid-cols-[18rem_1fr]"> <div className="grid gap-3 md:grid-cols-[18rem_1fr]">
<ul className="flex max-h-[60vh] flex-col gap-1 overflow-y-auto rounded-lg border bg-card/40 p-2"> <ul className="flex max-h-[60vh] flex-col gap-1 overflow-y-auto rounded-lg border bg-card/40 p-2">
{filtered.length === 0 && ( {filtered.length === 0 && (
@@ -91,9 +115,7 @@ export default function LibraryRoute() {
</span> </span>
<span className="line-clamp-1 text-[11px] text-muted-foreground"> <span className="line-clamp-1 text-[11px] text-muted-foreground">
{it.agentName ? `${it.agentName} · ` : ""} {it.agentName ? `${it.agentName} · ` : ""}
{it.messageCount {it.messageCount ? `${it.messageCount} msg · ` : ""}
? `${it.messageCount} msg · `
: ""}
{new Date(it.createdAt).toLocaleDateString()} {new Date(it.createdAt).toLocaleDateString()}
</span> </span>
</span> </span>
@@ -106,30 +128,13 @@ export default function LibraryRoute() {
{open ? <Detail item={open} /> : <PickAnItem />} {open ? <Detail item={open} /> : <PickAnItem />}
</div> </div>
</div> </div>
)} </CardContent>
</CardContent> </Card>
</Card> )}
</AppShell> </AppShell>
) )
} }
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/30 p-12 text-center">
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground">
<BookOpen className="size-6" />
</div>
<div className="max-w-md">
<p className="font-medium">Library is empty</p>
<p className="mt-1 text-sm text-muted-foreground">
Save a conversation from the Assistant via the menu {" "}
<span className="font-medium">Save to Library</span>.
</p>
</div>
</div>
)
}
function PickAnItem() { function PickAnItem() {
return ( return (
<div className="flex h-full items-center justify-center rounded-lg border border-dashed border-muted-foreground/20 p-12 text-center text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center rounded-lg border border-dashed border-muted-foreground/20 p-12 text-center text-sm text-muted-foreground">
@@ -139,11 +144,13 @@ function PickAnItem() {
} }
function Detail({ item }: { item: LibraryItem }) { function Detail({ item }: { item: LibraryItem }) {
const { toast } = useToast()
const copy = async () => { const copy = async () => {
try { try {
await navigator.clipboard.writeText(item.content) await navigator.clipboard.writeText(item.content)
toast({ title: "Copied to clipboard", tone: "success" })
} catch { } catch {
/* ignore */ toast({ title: "Couldn't copy", tone: "error" })
} }
} }
const download = () => { const download = () => {
@@ -153,14 +160,12 @@ function Detail({ item }: { item: LibraryItem }) {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement("a") const a = document.createElement("a")
a.href = url a.href = url
const slug = item.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 60) || "item" const slug =
item.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 60) || "item"
a.download = `${slug}.md` a.download = `${slug}.md`
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
const remove = () => {
if (window.confirm(`Delete "${item.title}"?`)) deleteLibraryItem(item.id)
}
return ( return (
<div className="flex max-h-[60vh] flex-col rounded-lg border bg-card/40"> <div className="flex max-h-[60vh] flex-col rounded-lg border bg-card/40">
<div className="flex items-start gap-2 border-b px-3 py-2"> <div className="flex items-start gap-2 border-b px-3 py-2">
@@ -188,14 +193,29 @@ function Detail({ item }: { item: LibraryItem }) {
> >
<Download className="size-3.5" /> Download <Download className="size-3.5" /> Download
</Button> </Button>
<Button <ConfirmDialog
data-action={`library-delete-${item.id}`} trigger={
variant="ghost" <Button
size="sm" data-action={`library-delete-${item.id}`}
onClick={remove} variant="ghost"
> size="sm"
<Trash2 className="size-3.5 text-destructive" /> >
</Button> <Trash2 className="size-3.5 text-destructive" />
</Button>
}
title="Delete saved item?"
description={`"${item.title}" will be permanently removed. This can't be undone.`}
confirmLabel="Delete"
destructive
onConfirm={() => {
deleteLibraryItem(item.id)
toast({
title: "Item deleted",
description: item.title,
tone: "success",
})
}}
/>
</div> </div>
<pre className="flex-1 overflow-auto whitespace-pre-wrap p-4 font-mono text-xs leading-relaxed"> <pre className="flex-1 overflow-auto whitespace-pre-wrap p-4 font-mono text-xs leading-relaxed">
{item.content} {item.content}

View File

@@ -1,15 +1,14 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { Plus, Search, Trash2 } from "lucide-react" import { Boxes, Plus, Search, Trash2 } from "lucide-react"
import { useToast } from "@crema/notification-ui"
import { AppShell } from "~/components/layout/app-shell" import { AppShell } from "~/components/layout/app-shell"
import { PageHeader } from "~/components/layout/page-header"
import { ConfirmDialog } from "~/components/confirm-dialog"
import { ListSkeleton } from "~/components/loading"
import { EmptyState, ErrorState } from "~/components/states"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { import { Card, CardContent } from "~/components/ui/card"
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Input } from "~/components/ui/input" import { Input } from "~/components/ui/input"
import { import {
createResource, createResource,
@@ -27,11 +26,24 @@ const statuses: Resource["status"][] = ["active", "paused", "archived"]
export default function ResourcesRoute() { export default function ResourcesRoute() {
const items = useResources() const items = useResources()
const { toast } = useToast()
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [draftName, setDraftName] = useState("") const [draftName, setDraftName] = useState("")
// Load-state grammar: skeleton → error → empty → content. The store here
// is synchronous localStorage, so `loading` just gates the first frame;
// a fork swapping `useResources()` for an async `api.get` drives these
// two flags off the real request instead.
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
seedResourcesIfEmpty() try {
seedResourcesIfEmpty()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, []) }, [])
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -51,133 +63,177 @@ export default function ResourcesRoute() {
if (!name) return if (!name) return
createResource({ name, owner: "You" }) createResource({ name, owner: "You" })
setDraftName("") setDraftName("")
toast({ title: "Resource added", description: name, tone: "success" })
} }
return ( return (
<AppShell title="Resources"> <AppShell title="Resources">
<Card> <PageHeader
<CardHeader> title="Resources"
<CardTitle>Resources</CardTitle> description={
<CardDescription> <>
Example domain entity. CRUD goes through{" "} Example domain entity. CRUD goes through{" "}
<code className="font-mono text-xs">~/lib/resources.ts</code> <code className="font-mono text-xs">~/lib/resources.ts</code> swap
swap that file's calls for{" "} that file's calls for{" "}
<code className="font-mono text-xs">api.get/post/put/del</code>{" "} <code className="font-mono text-xs">api.get/post/put/del</code> from{" "}
from <code className="font-mono text-xs">~/lib/api.ts</code> when <code className="font-mono text-xs">~/lib/api.ts</code> when you have
you have a backend. a backend.
</CardDescription> </>
</CardHeader> }
<CardContent className="flex flex-col gap-4"> />
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-48"> {loading ? (
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" /> <ListSkeleton />
<Input ) : error ? (
data-action="resources-search" <ErrorState
value={query} message={error}
onChange={(e) => setQuery(e.target.value)} action={
placeholder="Search name, owner, status…"
className="pl-8"
/>
</div>
<Input
data-action="resources-new-name"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") create()
}}
placeholder="New resource name…"
className="max-w-64"
/>
<Button <Button
data-action="resources-create" data-action="resources-retry"
onClick={create} variant="outline"
disabled={!draftName.trim()} size="sm"
onClick={() => {
setError(null)
setLoading(true)
queueMicrotask(() => setLoading(false))
}}
> >
<Plus className="size-4" /> Add Retry
</Button> </Button>
</div> }
/>
) : (
<Card>
<CardContent className="flex flex-col gap-4 pt-6">
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-48">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
data-action="resources-search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search name, owner, status…"
className="pl-8"
/>
</div>
<Input
data-action="resources-new-name"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") create()
}}
placeholder="New resource name…"
className="max-w-64"
/>
<Button
data-action="resources-create"
onClick={create}
disabled={!draftName.trim()}
>
<Plus className="size-4" /> Add
</Button>
</div>
<div className="overflow-hidden rounded-lg border bg-card/40"> {items.length === 0 ? (
<table className="w-full text-sm"> <EmptyState
<thead className="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground"> icon={<Boxes className="size-6" />}
<tr> title="No resources yet"
<th className="px-3 py-2 text-left font-medium">Name</th> description="Add your first resource with the field above. Everything here is backed by ~/lib/resources.ts."
<th className="px-3 py-2 text-left font-medium">Owner</th> />
<th className="px-3 py-2 text-left font-medium">Status</th> ) : (
<th className="px-3 py-2 text-left font-medium">Updated</th> <div className="overflow-hidden rounded-lg border bg-card/40">
<th className="w-10 px-3 py-2"></th> <table className="w-full text-sm">
</tr> <thead className="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
</thead> <tr>
<tbody> <th className="px-3 py-2 text-left font-medium">Name</th>
{filtered.length === 0 ? ( <th className="px-3 py-2 text-left font-medium">Owner</th>
<tr> <th className="px-3 py-2 text-left font-medium">Status</th>
<td <th className="px-3 py-2 text-left font-medium">Updated</th>
colSpan={5} <th className="w-10 px-3 py-2"></th>
className="px-3 py-8 text-center text-muted-foreground"
>
{items.length === 0
? "No resources yet — add one above."
: "No matches."}
</td>
</tr>
) : (
filtered.map((r) => (
<tr
key={r.id}
className="border-t transition-colors hover:bg-accent/30"
>
<td className="px-3 py-2 font-medium">{r.name}</td>
<td className="px-3 py-2 text-muted-foreground">
{r.owner}
</td>
<td className="px-3 py-2">
<select
data-action={`resources-status-${r.id}`}
value={r.status}
onChange={(e) =>
updateResource(r.id, {
status: e.target.value as Resource["status"],
})
}
className="rounded-md border bg-background px-1.5 py-0.5 text-xs"
>
{statuses.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
{new Date(r.updatedAt).toLocaleDateString()}
</td>
<td className="px-2 py-2 text-right">
<Button
data-action={`resources-delete-${r.id}`}
variant="ghost"
size="icon-sm"
aria-label="Delete"
onClick={() => {
if (window.confirm(`Delete "${r.name}"?`))
deleteResource(r.id)
}}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</td>
</tr> </tr>
)) </thead>
)} <tbody>
</tbody> {filtered.length === 0 ? (
</table> <tr>
</div> <td
colSpan={5}
className="px-3 py-8 text-center text-muted-foreground"
>
No matches.
</td>
</tr>
) : (
filtered.map((r) => (
<tr
key={r.id}
className="border-t transition-colors hover:bg-accent/30"
>
<td className="px-3 py-2 font-medium">{r.name}</td>
<td className="px-3 py-2 text-muted-foreground">
{r.owner}
</td>
<td className="px-3 py-2">
<select
data-action={`resources-status-${r.id}`}
value={r.status}
onChange={(e) =>
updateResource(r.id, {
status: e.target.value as Resource["status"],
})
}
className="rounded-md border bg-background px-1.5 py-0.5 text-xs"
>
{statuses.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
{new Date(r.updatedAt).toLocaleDateString()}
</td>
<td className="px-2 py-2 text-right">
<ConfirmDialog
trigger={
<Button
data-action={`resources-delete-${r.id}`}
variant="ghost"
size="icon-sm"
aria-label="Delete"
>
<Trash2 className="size-4 text-destructive" />
</Button>
}
title="Delete resource?"
description={`"${r.name}" will be permanently removed. This can't be undone.`}
confirmLabel="Delete"
destructive
onConfirm={() => {
deleteResource(r.id)
toast({
title: "Resource deleted",
description: r.name,
tone: "success",
})
}}
/>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{items.length} total · {filtered.length} shown {items.length} total · {filtered.length} shown
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
)}
</AppShell> </AppShell>
) )
} }