From 675f6f8b35fb6978f4014b1fc52cae7e2fd79f21 Mon Sep 17 00:00:00 2001 From: jules Date: Sat, 4 Jul 2026 13:46:12 +1000 Subject: [PATCH] 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 --- app/components/confirm-dialog.tsx | 99 +++++++++ app/components/layout/app-shell.tsx | 59 ++--- app/components/layout/page-header.tsx | 36 +++ app/components/loading.tsx | 98 +++++++++ app/components/states.tsx | 114 ++++++++++ app/lib/errors.ts | 73 ++++++ app/lib/notifications.ts | 26 --- app/root.tsx | 66 ++++-- app/routes/activity.tsx | 47 ++-- app/routes/assistant.tsx | 37 +++- app/routes/home.tsx | 23 +- app/routes/library.tsx | 144 ++++++------ app/routes/resources.tsx | 306 +++++++++++++++----------- 13 files changed, 809 insertions(+), 319 deletions(-) create mode 100644 app/components/confirm-dialog.tsx create mode 100644 app/components/layout/page-header.tsx create mode 100644 app/components/loading.tsx create mode 100644 app/components/states.tsx create mode 100644 app/lib/errors.ts diff --git a/app/components/confirm-dialog.tsx b/app/components/confirm-dialog.tsx new file mode 100644 index 0000000..921f086 --- /dev/null +++ b/app/components/confirm-dialog.tsx @@ -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 +} + +/** 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(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 ( + { + if (!busy) setOpen(v) + }} + > + + + + {title} + {description ? ( + {description} + ) : null} + + {error ?

{error}

: null} + + + Cancel + + { + e.preventDefault() + void handleConfirm() + }} + disabled={busy} + className={ + destructive + ? "bg-destructive text-destructive-foreground hover:bg-destructive/90" + : undefined + } + > + {busy ? ( + <> + Working… + + ) : ( + confirmLabel + )} + + +
+
+ ) +} diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index 5601919..639713a 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react" const SIDEBAR_KEY = "crema.shell.sidebar" -import { NavLink, useNavigate } from "react-router" +import { NavLink, Navigate, useNavigate } from "react-router" import { Bell, LayoutDashboard, @@ -52,7 +52,6 @@ import { dismissAll, markAllRead, markRead, - seedIfEmpty, unreadCount, useNotifications, } from "~/lib/notifications" @@ -66,7 +65,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu" -import { Input } from "~/components/ui/input" import { Sheet, SheetContent, @@ -129,17 +127,10 @@ export function AppShell({ ), } - // Protected shell: bounce to /login when there's no session. - useEffect(() => { - if (typeof window === "undefined") return - if (!session) { - const next = encodeURIComponent( - window.location.pathname + window.location.search, - ) - navigate(`/login?next=${next}`, { replace: true }) - } - }, [session, navigate]) - if (!session) return null + // All hooks must be called unconditionally — declare them BEFORE any + // early return so React's render-time hook count is stable. (Previously + // `if (!session) return null` sat above these hooks, so the hook count + // changed the moment the session flipped → hook-order crash.) const [expanded, setExpanded] = useState(() => { if (typeof window === "undefined") return false return localStorage.getItem(SIDEBAR_KEY) === "1" @@ -149,10 +140,20 @@ export function AppShell({ }, [expanded]) const [mobileOpen, setMobileOpen] = useState(false) const [scriptsOpen, setScriptsOpen] = useState(false) - const BrandIcon = brand.icon - 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 + } + const BrandIcon = brand.icon + return (
{title} -
- - -
+ {/* Honest search affordance: the template has no search index, so + this routes to the Assistant — the app's real natural-language + surface — rather than being a dead . Forks with a search + route should point this at it (and add a ⌘K palette hint). */} + +
+ {stack ? ( +
+            {stack}
+          
+ ) : null} + ) } diff --git a/app/routes/activity.tsx b/app/routes/activity.tsx index 9d43620..9800b62 100644 --- a/app/routes/activity.tsx +++ b/app/routes/activity.tsx @@ -1,13 +1,8 @@ import { Activity } from "lucide-react" import { AppShell } from "~/components/layout/app-shell" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card" +import { PageHeader } from "~/components/layout/page-header" +import { EmptyState } from "~/components/states" import { pageTitle } from "~/lib/page-meta" export const meta = () => pageTitle("Activity") @@ -15,29 +10,21 @@ export const meta = () => pageTitle("Activity") export default function ActivityRoute() { return ( - - - Activity - - Event stream, audit log, recent changes. - - - -
-
- -
-
-

No activity yet

-

- Once your app is doing things, this is where audit events, - webhook deliveries, and recent changes show up — pair with{" "} - @crema/log-ui. -

-
-
-
-
+ + } + title="No activity yet" + description={ + <> + Once your app is doing things, this is where audit events, webhook + deliveries, and recent changes show up — pair with{" "} + @crema/log-ui. + + } + />
) } diff --git a/app/routes/assistant.tsx b/app/routes/assistant.tsx index 1bd35b9..865e64f 100644 --- a/app/routes/assistant.tsx +++ b/app/routes/assistant.tsx @@ -111,6 +111,7 @@ import { TypingIndicator } from "@crema/chat-ui" import { CommandBar } from "@crema/aifirst-ui" import { AppShell } from "~/components/layout/app-shell" +import { ConfirmDialog } from "~/components/confirm-dialog" import { MessageBody } from "~/components/assistant/message-body" import { Button } from "~/components/ui/button" import { @@ -1956,20 +1957,32 @@ function ThreadsPicker({ > - + } + title="Delete conversation?" + description={`"${t.title}" and its messages will be permanently removed.`} + confirmLabel="Delete" + destructive + onConfirm={() => { 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" - > - - + /> ) })} diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 2e816e3..a2a55a0 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -2,13 +2,8 @@ import { ArrowRight, Sparkles, Boxes, Activity, BookOpen } from "lucide-react" import { Link } from "react-router" import { AppShell } from "~/components/layout/app-shell" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card" +import { PageHeader } from "~/components/layout/page-header" +import { Card, CardDescription, CardHeader, CardTitle } from "~/components/ui/card" import { pageTitle } from "~/lib/page-meta" export const meta = () => pageTitle("Overview") @@ -44,19 +39,19 @@ const tiles = [ export default function HomeRoute() { return ( - - - Welcome - + A hybrid traditional + AI-first scaffold. Use the rail to navigate; the Assistant can drive the UI on your behalf — try{" "} ⌘⇧P {" "} for the script runner. - - - + + } + />
{tiles.map((t) => { diff --git a/app/routes/library.tsx b/app/routes/library.tsx index 9dd8d36..45875e9 100644 --- a/app/routes/library.tsx +++ b/app/routes/library.tsx @@ -1,15 +1,14 @@ -import { useState } from "react" +import { useEffect, useState } from "react" import { BookOpen, Copy, Download, Trash2, MessagesSquare } from "lucide-react" +import { useToast } from "@crema/notification-ui" 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 { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card" +import { Card, CardContent } from "~/components/ui/card" import { Input } from "~/components/ui/input" import { pageTitle } from "~/lib/page-meta" import { @@ -24,6 +23,15 @@ export default function LibraryRoute() { const items = useLibrary() const [query, setQuery] = useState("") const [openId, setOpenId] = useState(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(null) + + useEffect(() => { + setLoading(false) + }, []) const filtered = items.filter((it) => { if (!query.trim()) return true @@ -39,25 +47,41 @@ export default function LibraryRoute() { return ( - - - Library - - Saved items and templates. Save a chat from the Assistant via the - ⋯ menu → "Save to Library". - - - - setQuery(e.target.value)} - /> + + Saved items and templates. Save a chat from the Assistant via the ⋯ + menu → Save to Library. + + } + /> + + {loading ? ( + + ) : error ? ( + + ) : items.length === 0 ? ( + } + title="Library is empty" + description={ + <> + Save a conversation from the Assistant via the ⋯ menu →{" "} + Save to Library. + + } + /> + ) : ( + + + setQuery(e.target.value)} + /> - {items.length === 0 ? ( - - ) : (
    {filtered.length === 0 && ( @@ -91,9 +115,7 @@ export default function LibraryRoute() { {it.agentName ? `${it.agentName} · ` : ""} - {it.messageCount - ? `${it.messageCount} msg · ` - : ""} + {it.messageCount ? `${it.messageCount} msg · ` : ""} {new Date(it.createdAt).toLocaleDateString()} @@ -106,30 +128,13 @@ export default function LibraryRoute() { {open ? : }
- )} - - + + + )}
) } -function EmptyState() { - return ( -
-
- -
-
-

Library is empty

-

- Save a conversation from the Assistant via the ⋯ menu →{" "} - Save to Library. -

-
-
- ) -} - function PickAnItem() { return (
@@ -139,11 +144,13 @@ function PickAnItem() { } function Detail({ item }: { item: LibraryItem }) { + const { toast } = useToast() const copy = async () => { try { await navigator.clipboard.writeText(item.content) + toast({ title: "Copied to clipboard", tone: "success" }) } catch { - /* ignore */ + toast({ title: "Couldn't copy", tone: "error" }) } } const download = () => { @@ -153,14 +160,12 @@ function Detail({ item }: { item: LibraryItem }) { const url = URL.createObjectURL(blob) const a = document.createElement("a") 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.click() URL.revokeObjectURL(url) } - const remove = () => { - if (window.confirm(`Delete "${item.title}"?`)) deleteLibraryItem(item.id) - } return (
@@ -188,14 +193,29 @@ function Detail({ item }: { item: LibraryItem }) { > Download - + + + + } + 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", + }) + }} + />
         {item.content}
diff --git a/app/routes/resources.tsx b/app/routes/resources.tsx
index 4dda33c..20c7998 100644
--- a/app/routes/resources.tsx
+++ b/app/routes/resources.tsx
@@ -1,15 +1,14 @@
 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 { 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 {
-  Card,
-  CardContent,
-  CardDescription,
-  CardHeader,
-  CardTitle,
-} from "~/components/ui/card"
+import { Card, CardContent } from "~/components/ui/card"
 import { Input } from "~/components/ui/input"
 import {
   createResource,
@@ -27,11 +26,24 @@ const statuses: Resource["status"][] = ["active", "paused", "archived"]
 
 export default function ResourcesRoute() {
   const items = useResources()
+  const { toast } = useToast()
   const [query, setQuery] = 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(null)
 
   useEffect(() => {
-    seedResourcesIfEmpty()
+    try {
+      seedResourcesIfEmpty()
+    } catch (e) {
+      setError(e instanceof Error ? e.message : String(e))
+    } finally {
+      setLoading(false)
+    }
   }, [])
 
   const filtered = useMemo(() => {
@@ -51,133 +63,177 @@ export default function ResourcesRoute() {
     if (!name) return
     createResource({ name, owner: "You" })
     setDraftName("")
+    toast({ title: "Resource added", description: name, tone: "success" })
   }
 
   return (
     
-      
-        
-          Resources
-          
+      
             Example domain entity. CRUD goes through{" "}
-            ~/lib/resources.ts —
-            swap that file's calls for{" "}
-            api.get/post/put/del{" "}
-            from ~/lib/api.ts when
-            you have a backend.
-          
-        
-        
-          
-
- - setQuery(e.target.value)} - placeholder="Search name, owner, status…" - className="pl-8" - /> -
- setDraftName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") create() - }} - placeholder="New resource name…" - className="max-w-64" - /> + ~/lib/resources.ts — swap + that file's calls for{" "} + api.get/post/put/del from{" "} + ~/lib/api.ts when you have + a backend. + + } + /> + + {loading ? ( + + ) : error ? ( + { + setError(null) + setLoading(true) + queueMicrotask(() => setLoading(false)) + }} > - Add + Retry -
+ } + /> + ) : ( + + +
+
+ + setQuery(e.target.value)} + placeholder="Search name, owner, status…" + className="pl-8" + /> +
+ setDraftName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") create() + }} + placeholder="New resource name…" + className="max-w-64" + /> + +
-
- - - - - - - - - - - - {filtered.length === 0 ? ( - - - - ) : ( - filtered.map((r) => ( - - - - - - + {items.length === 0 ? ( + } + title="No resources yet" + description="Add your first resource with the field above. Everything here is backed by ~/lib/resources.ts." + /> + ) : ( +
+
NameOwnerStatusUpdated
- {items.length === 0 - ? "No resources yet — add one above." - : "No matches."} -
{r.name} - {r.owner} - - - - {new Date(r.updatedAt).toLocaleDateString()} - - -
+ + + + + + + - )) - )} - -
NameOwnerStatusUpdated
-
+ + + {filtered.length === 0 ? ( + + + No matches. + + + ) : ( + filtered.map((r) => ( + + {r.name} + + {r.owner} + + + + + + {new Date(r.updatedAt).toLocaleDateString()} + + + + + + } + 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", + }) + }} + /> + + + )) + )} + + +
+ )} -

- {items.length} total · {filtered.length} shown -

- - +

+ {items.length} total · {filtered.length} shown +

+ + + )} ) }