feat: auth scaffold, notifications inbox, resources CRUD, vitest baseline, typed API client
Auth - ~/lib/session.ts: Session type + loadSession/signIn/signOut/hasSession, reactive useSession hook (mock backend; replace fetch calls with your real auth endpoint when ready) - routes/login.tsx: form with email/password (mock-validated), bounces to ?next= on success - AppShell: redirects to /login when no session; account-menu Sign out now actually signs out; live session.name/email used for the appbar avatar (falls back to profile) Notifications - ~/lib/notifications.ts: persistent inbox with kinds (info/success/ warning/error), unreadCount, markRead, markAllRead, dismiss, dismissAll; seedIfEmpty for a friendly first-run - AppShell bell: 320px popover with badge, kind dots, per-row open (navigates to href) and dismiss; Mark all read + Clear actions - Hidden NotificationDispatcher in AppShell so the action bus can create real notifications via fill notif-title / notif-body / notif-kind / notif-href + click notif-create Data layer - ~/lib/api.ts: typed apiFetch<T> + api.get/post/put/patch/del, auto-attaches the session token, throws structured ApiError, signs out on 401 - ~/lib/resources.ts: example domain entity (CRUD) backed by localStorage today; each call is a 1:1 swap for api.get/post/put/del - routes/resources.tsx: real working table — search, add, inline status edit, delete; seeded demo rows on first load Tests - vitest + jsdom + @testing-library/react + @testing-library/jest-dom + vite-tsconfig-paths installed - vitest.config.ts (jsdom, globals, ~ aliases via tsconfig-paths) - vitest.setup.ts (RTL cleanup + localStorage clear between tests) - app/lib/session.test.ts and resources.test.ts as starter coverage - npm test / npm run test:watch scripts UI Control catalog - Login form, resources CRUD, notifications inbox, and the hidden notif-bridge ids tagged so the assistant can drive every new surface Threads - ThreadMessage now carries optional agentId so per-message authorship survives persona switches and handoffs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, 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, useNavigate } from "react-router"
|
||||||
@@ -39,7 +39,23 @@ import { BackgroundPicker } from "~/components/layout/background-picker"
|
|||||||
import { FontSizePicker } from "~/components/layout/font-size-picker"
|
import { FontSizePicker } from "~/components/layout/font-size-picker"
|
||||||
import { SurfacePicker } from "~/components/layout/surface-picker"
|
import { SurfacePicker } from "~/components/layout/surface-picker"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "~/components/ui/popover"
|
||||||
import { profileInitials, useProfile } from "~/lib/profile"
|
import { profileInitials, useProfile } from "~/lib/profile"
|
||||||
|
import { signOut, useSession } from "~/lib/session"
|
||||||
|
import {
|
||||||
|
addNotification,
|
||||||
|
dismiss,
|
||||||
|
dismissAll,
|
||||||
|
markAllRead,
|
||||||
|
markRead,
|
||||||
|
seedIfEmpty,
|
||||||
|
unreadCount,
|
||||||
|
useNotifications,
|
||||||
|
} from "~/lib/notifications"
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -100,12 +116,30 @@ export function AppShell({
|
|||||||
const defaultBrand = useBrand()
|
const defaultBrand = useBrand()
|
||||||
const defaultUser = useUser()
|
const defaultUser = useUser()
|
||||||
const profile = useProfile()
|
const profile = useProfile()
|
||||||
|
const session = useSession()
|
||||||
|
const navigate = useNavigate()
|
||||||
const brand = brandOverride ?? defaultBrand
|
const brand = brandOverride ?? defaultBrand
|
||||||
|
// Prefer the live session for identity, fall back to the editable profile,
|
||||||
|
// fall back to the stub user.
|
||||||
const user = userOverride ?? {
|
const user = userOverride ?? {
|
||||||
name: profile.name || defaultUser.name,
|
name: session?.name || profile.name || defaultUser.name,
|
||||||
email: profile.email || defaultUser.email,
|
email: session?.email || profile.email || defaultUser.email,
|
||||||
initials: profileInitials(profile.name || defaultUser.name),
|
initials: profileInitials(
|
||||||
|
session?.name || profile.name || defaultUser.name,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
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"
|
||||||
@@ -115,7 +149,6 @@ 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 navigate = useNavigate()
|
|
||||||
const BrandIcon = brand.icon
|
const BrandIcon = brand.icon
|
||||||
|
|
||||||
useScriptsHotkey(() => setScriptsOpen(true))
|
useScriptsHotkey(() => setScriptsOpen(true))
|
||||||
@@ -280,14 +313,8 @@ export function AppShell({
|
|||||||
<SurfacePicker />
|
<SurfacePicker />
|
||||||
<BackgroundPicker />
|
<BackgroundPicker />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Button
|
<NotificationsBell />
|
||||||
data-action="appbar-notifications"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label="Notifications"
|
|
||||||
>
|
|
||||||
<Bell />
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
data-action="appbar-avatar"
|
data-action="appbar-avatar"
|
||||||
@@ -327,7 +354,14 @@ export function AppShell({
|
|||||||
<HelpCircle /> Help
|
<HelpCircle /> Help
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem data-action="avatar-signout" variant="destructive">
|
<DropdownMenuItem
|
||||||
|
data-action="avatar-signout"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
signOut()
|
||||||
|
navigate("/login", { replace: true })
|
||||||
|
}}
|
||||||
|
>
|
||||||
<LogOut /> Sign out
|
<LogOut /> Sign out
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -345,6 +379,195 @@ export function AppShell({
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<ScriptsDialog open={scriptsOpen} onOpenChange={setScriptsOpen} />
|
<ScriptsDialog open={scriptsOpen} onOpenChange={setScriptsOpen} />
|
||||||
|
<NotificationDispatcher />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotificationDispatcher() {
|
||||||
|
// Hidden bridge so the action bus can create real notifications:
|
||||||
|
// fill notify-title "Hello"
|
||||||
|
// fill notify-body "Body text"
|
||||||
|
// fill notify-kind "info" # info|success|warning|error
|
||||||
|
// fill notify-href "/library" # optional
|
||||||
|
// click notify-create
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null)
|
||||||
|
const bodyRef = useRef<HTMLInputElement>(null)
|
||||||
|
const kindRef = useRef<HTMLInputElement>(null)
|
||||||
|
const hrefRef = useRef<HTMLInputElement>(null)
|
||||||
|
const submit = () => {
|
||||||
|
const title = titleRef.current?.value.trim() ?? ""
|
||||||
|
if (!title) return
|
||||||
|
const body = bodyRef.current?.value.trim() || undefined
|
||||||
|
const rawKind = (kindRef.current?.value || "info").trim().toLowerCase()
|
||||||
|
const kind = (
|
||||||
|
["info", "success", "warning", "error"].includes(rawKind)
|
||||||
|
? rawKind
|
||||||
|
: "info"
|
||||||
|
) as "info" | "success" | "warning" | "error"
|
||||||
|
const href = hrefRef.current?.value.trim() || undefined
|
||||||
|
addNotification({ title, body, kind, href })
|
||||||
|
if (titleRef.current) titleRef.current.value = ""
|
||||||
|
if (bodyRef.current) bodyRef.current.value = ""
|
||||||
|
if (kindRef.current) kindRef.current.value = ""
|
||||||
|
if (hrefRef.current) hrefRef.current.value = ""
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none fixed left-0 top-0 size-px overflow-hidden opacity-0"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={titleRef}
|
||||||
|
data-action="notif-title"
|
||||||
|
placeholder="title"
|
||||||
|
aria-label="Notification title"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={bodyRef}
|
||||||
|
data-action="notif-body"
|
||||||
|
placeholder="body"
|
||||||
|
aria-label="Notification body"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={kindRef}
|
||||||
|
data-action="notif-kind"
|
||||||
|
placeholder="info|success|warning|error"
|
||||||
|
aria-label="Notification kind"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={hrefRef}
|
||||||
|
data-action="notif-href"
|
||||||
|
placeholder="/path (optional)"
|
||||||
|
aria-label="Notification href"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="notif-create"
|
||||||
|
aria-label="Create notification"
|
||||||
|
onClick={submit}
|
||||||
|
className="pointer-events-auto"
|
||||||
|
>
|
||||||
|
create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationsBell() {
|
||||||
|
const items = useNotifications()
|
||||||
|
const unread = unreadCount(items)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
seedIfEmpty()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
data-action="appbar-notifications"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Notifications"
|
||||||
|
>
|
||||||
|
<span className="relative inline-flex">
|
||||||
|
<Bell />
|
||||||
|
{unread > 0 && (
|
||||||
|
<span className="absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-semibold text-primary-foreground">
|
||||||
|
{unread > 9 ? "9+" : unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContent align="end" className="w-80 p-0">
|
||||||
|
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||||
|
<span className="text-sm font-semibold">Notifications</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
data-action="notif-mark-all-read"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => markAllRead()}
|
||||||
|
disabled={unread === 0}
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-action="notif-clear"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => dismissAll()}
|
||||||
|
disabled={items.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="max-h-80 overflow-y-auto">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<li className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No notifications.
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
items.map((n) => (
|
||||||
|
<li
|
||||||
|
key={n.id}
|
||||||
|
className={
|
||||||
|
"group flex items-start gap-2 border-b px-3 py-2 text-sm transition-colors hover:bg-accent/40 " +
|
||||||
|
(!n.readAt ? "bg-primary/5" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"mt-1 size-2 shrink-0 rounded-full " +
|
||||||
|
(n.kind === "success"
|
||||||
|
? "bg-emerald-500"
|
||||||
|
: n.kind === "warning"
|
||||||
|
? "bg-amber-500"
|
||||||
|
: n.kind === "error"
|
||||||
|
? "bg-rose-500"
|
||||||
|
: "bg-primary")
|
||||||
|
}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action={`notif-open-${n.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
markRead(n.id)
|
||||||
|
if (n.href) navigate(n.href)
|
||||||
|
}}
|
||||||
|
className="flex flex-1 flex-col items-start text-left"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{n.title}</span>
|
||||||
|
{n.body && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{n.body}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-muted-foreground/70">
|
||||||
|
{new Date(n.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action={`notif-dismiss-${n.id}`}
|
||||||
|
onClick={() => dismiss(n.id)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
className="rounded p-1 text-muted-foreground opacity-0 hover:bg-background hover:text-foreground group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<span aria-hidden>×</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
83
app/lib/api.ts
Normal file
83
app/lib/api.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// API — typed fetch wrapper. Auto-injects the session token, throws on
|
||||||
|
// non-2xx with a parsed error, and supports AbortSignal for cancellation.
|
||||||
|
//
|
||||||
|
// Replace `apiBaseURL` with your backend root. The Resources route shows the
|
||||||
|
// typical usage pattern.
|
||||||
|
|
||||||
|
import { loadSession, signOut } from "~/lib/session"
|
||||||
|
|
||||||
|
export const apiBaseURL = "/api"
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(message: string, status: number, body: unknown) {
|
||||||
|
super(message)
|
||||||
|
this.name = "ApiError"
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiInit = Omit<RequestInit, "body" | "method"> & {
|
||||||
|
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
|
||||||
|
body?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch<T = unknown>(
|
||||||
|
path: string,
|
||||||
|
init: ApiInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const session = loadSession()
|
||||||
|
const headers = new Headers(init.headers)
|
||||||
|
if (session?.token) headers.set("Authorization", `Bearer ${session.token}`)
|
||||||
|
if (init.body !== undefined && !headers.has("Content-Type")) {
|
||||||
|
headers.set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = path.startsWith("http") ? path : `${apiBaseURL}${path}`
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
method: init.method ?? "GET",
|
||||||
|
headers,
|
||||||
|
body:
|
||||||
|
init.body === undefined
|
||||||
|
? undefined
|
||||||
|
: typeof init.body === "string"
|
||||||
|
? init.body
|
||||||
|
: JSON.stringify(init.body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
// Token rejected — clear session so the shell bounces to /login.
|
||||||
|
signOut()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ct = res.headers.get("Content-Type") ?? ""
|
||||||
|
const parsed = ct.includes("application/json")
|
||||||
|
? await res.json().catch(() => null)
|
||||||
|
: await res.text().catch(() => null)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const message =
|
||||||
|
(parsed && typeof parsed === "object" && "message" in parsed
|
||||||
|
? String((parsed as { message: unknown }).message)
|
||||||
|
: null) ?? `${res.status} ${res.statusText}`
|
||||||
|
throw new ApiError(message, res.status, parsed)
|
||||||
|
}
|
||||||
|
return parsed as T
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience helpers. */
|
||||||
|
export const api = {
|
||||||
|
get: <T = unknown>(path: string, init?: ApiInit) =>
|
||||||
|
apiFetch<T>(path, { ...init, method: "GET" }),
|
||||||
|
post: <T = unknown>(path: string, body?: unknown, init?: ApiInit) =>
|
||||||
|
apiFetch<T>(path, { ...init, method: "POST", body }),
|
||||||
|
put: <T = unknown>(path: string, body?: unknown, init?: ApiInit) =>
|
||||||
|
apiFetch<T>(path, { ...init, method: "PUT", body }),
|
||||||
|
patch: <T = unknown>(path: string, body?: unknown, init?: ApiInit) =>
|
||||||
|
apiFetch<T>(path, { ...init, method: "PATCH", body }),
|
||||||
|
del: <T = unknown>(path: string, init?: ApiInit) =>
|
||||||
|
apiFetch<T>(path, { ...init, method: "DELETE" }),
|
||||||
|
}
|
||||||
155
app/lib/notifications.ts
Normal file
155
app/lib/notifications.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
// Notifications — small reactive store for in-app toasts/inbox items.
|
||||||
|
// Pair with @crema/notification-ui's <ToastProvider /> for transient toasts;
|
||||||
|
// this store is for the appbar bell's persistent inbox.
|
||||||
|
|
||||||
|
import { useEffect, useSyncExternalStore } from "react"
|
||||||
|
|
||||||
|
export type NotificationKind = "info" | "success" | "warning" | "error"
|
||||||
|
|
||||||
|
export type AppNotification = {
|
||||||
|
id: string
|
||||||
|
kind: NotificationKind
|
||||||
|
title: string
|
||||||
|
body?: string
|
||||||
|
// Optional href to open when the row is clicked.
|
||||||
|
href?: string
|
||||||
|
createdAt: number
|
||||||
|
readAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "crema.notifications"
|
||||||
|
const CHANGE_EVENT = "crema:notifications-change"
|
||||||
|
const MAX_ITEMS = 200
|
||||||
|
|
||||||
|
function newId(): string {
|
||||||
|
return `n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFromStorage(): AppNotification[] {
|
||||||
|
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(
|
||||||
|
(n): n is AppNotification =>
|
||||||
|
n &&
|
||||||
|
typeof n.id === "string" &&
|
||||||
|
typeof n.title === "string" &&
|
||||||
|
typeof n.createdAt === "number" &&
|
||||||
|
["info", "success", "warning", "error"].includes(n.kind),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeToStorage(items: AppNotification[]) {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const trimmed = items.slice(0, MAX_ITEMS)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed))
|
||||||
|
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||||
|
} catch {
|
||||||
|
/* quota — drop silently */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadNotifications(): AppNotification[] {
|
||||||
|
return readFromStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addNotification(
|
||||||
|
n: Omit<AppNotification, "id" | "createdAt">,
|
||||||
|
): AppNotification {
|
||||||
|
const next: AppNotification = {
|
||||||
|
...n,
|
||||||
|
id: newId(),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
writeToStorage([next, ...readFromStorage()])
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markRead(id: string) {
|
||||||
|
const items = readFromStorage().map((n) =>
|
||||||
|
n.id === id ? { ...n, readAt: Date.now() } : n,
|
||||||
|
)
|
||||||
|
writeToStorage(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markAllRead() {
|
||||||
|
const now = Date.now()
|
||||||
|
const items = readFromStorage().map((n) =>
|
||||||
|
n.readAt ? n : { ...n, readAt: now },
|
||||||
|
)
|
||||||
|
writeToStorage(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismiss(id: string) {
|
||||||
|
writeToStorage(readFromStorage().filter((n) => n.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissAll() {
|
||||||
|
writeToStorage([])
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: AppNotification[] | 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(): AppNotification[] {
|
||||||
|
if (!cached) cached = readFromStorage()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
function getServerSnapshot(): AppNotification[] {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotifications(): AppNotification[] {
|
||||||
|
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||||
|
useEffect(() => {
|
||||||
|
cached = null
|
||||||
|
}, [])
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unreadCount(items: AppNotification[]): number {
|
||||||
|
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)
|
||||||
|
}
|
||||||
32
app/lib/resources.test.ts
Normal file
32
app/lib/resources.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it, beforeEach } from "vitest"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createResource,
|
||||||
|
deleteResource,
|
||||||
|
listResources,
|
||||||
|
updateResource,
|
||||||
|
} from "./resources"
|
||||||
|
|
||||||
|
describe("resources", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("creates, updates, and deletes", () => {
|
||||||
|
expect(listResources()).toEqual([])
|
||||||
|
const r = createResource({ name: "Test", owner: "Atlas" })
|
||||||
|
expect(r.status).toBe("active")
|
||||||
|
expect(listResources()).toHaveLength(1)
|
||||||
|
|
||||||
|
const updated = updateResource(r.id, { status: "paused" })
|
||||||
|
expect(updated?.status).toBe("paused")
|
||||||
|
expect(updated?.updatedAt).toBeGreaterThanOrEqual(r.updatedAt)
|
||||||
|
|
||||||
|
deleteResource(r.id)
|
||||||
|
expect(listResources()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("ignores updates for unknown ids", () => {
|
||||||
|
expect(updateResource("missing", { name: "x" })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
157
app/lib/resources.ts
Normal file
157
app/lib/resources.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
// Resource store — example domain entity.
|
||||||
|
// Backed by localStorage today, but written so each call is a single function
|
||||||
|
// you can swap with `api.get/post/put/del` once you have a real backend.
|
||||||
|
|
||||||
|
import { useEffect, useSyncExternalStore } from "react"
|
||||||
|
|
||||||
|
export type Resource = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: "active" | "paused" | "archived"
|
||||||
|
owner: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "crema.resources"
|
||||||
|
const CHANGE_EVENT = "crema:resources-change"
|
||||||
|
|
||||||
|
function newId() {
|
||||||
|
return `r-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFromStorage(): Resource[] {
|
||||||
|
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(
|
||||||
|
(r): r is Resource =>
|
||||||
|
r &&
|
||||||
|
typeof r.id === "string" &&
|
||||||
|
typeof r.name === "string" &&
|
||||||
|
["active", "paused", "archived"].includes(r.status) &&
|
||||||
|
typeof r.owner === "string" &&
|
||||||
|
typeof r.createdAt === "number" &&
|
||||||
|
typeof r.updatedAt === "number",
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(items: Resource[]) {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
|
||||||
|
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||||
|
} catch {
|
||||||
|
/* quota */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRUD — these mirror what `api.get/post/put/del` would look like.
|
||||||
|
export function listResources(): Resource[] {
|
||||||
|
return readFromStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResource(input: {
|
||||||
|
name: string
|
||||||
|
owner: string
|
||||||
|
status?: Resource["status"]
|
||||||
|
}): Resource {
|
||||||
|
const now = Date.now()
|
||||||
|
const r: Resource = {
|
||||||
|
id: newId(),
|
||||||
|
name: input.name,
|
||||||
|
owner: input.owner,
|
||||||
|
status: input.status ?? "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
write([r, ...readFromStorage()])
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateResource(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Omit<Resource, "id" | "createdAt">>,
|
||||||
|
): Resource | null {
|
||||||
|
const items = readFromStorage()
|
||||||
|
let updated: Resource | null = null
|
||||||
|
const next = items.map((r) => {
|
||||||
|
if (r.id !== id) return r
|
||||||
|
updated = { ...r, ...patch, updatedAt: Date.now() }
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
if (updated) write(next)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteResource(id: string) {
|
||||||
|
write(readFromStorage().filter((r) => r.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: Resource[] | null = null
|
||||||
|
function subscribe(cb: () => 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(): Resource[] {
|
||||||
|
if (!cached) cached = readFromStorage()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
function getServerSnapshot(): Resource[] {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResources(): Resource[] {
|
||||||
|
const v = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||||
|
useEffect(() => {
|
||||||
|
cached = null
|
||||||
|
}, [])
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed a few rows on first load so the table isn't empty. */
|
||||||
|
export function seedResourcesIfEmpty() {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
if (localStorage.getItem(STORAGE_KEY)) return
|
||||||
|
const now = Date.now()
|
||||||
|
const seed: Resource[] = [
|
||||||
|
{
|
||||||
|
id: newId(),
|
||||||
|
name: "Acme dashboard",
|
||||||
|
status: "active",
|
||||||
|
owner: "Atlas",
|
||||||
|
createdAt: now - 86_400_000 * 3,
|
||||||
|
updatedAt: now - 3600_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: newId(),
|
||||||
|
name: "Onboarding pipeline",
|
||||||
|
status: "paused",
|
||||||
|
owner: "Forge",
|
||||||
|
createdAt: now - 86_400_000 * 7,
|
||||||
|
updatedAt: now - 86_400_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: newId(),
|
||||||
|
name: "Q1 report draft",
|
||||||
|
status: "archived",
|
||||||
|
owner: "Inkwell",
|
||||||
|
createdAt: now - 86_400_000 * 30,
|
||||||
|
updatedAt: now - 86_400_000 * 14,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
write(seed)
|
||||||
|
}
|
||||||
31
app/lib/session.test.ts
Normal file
31
app/lib/session.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it, beforeEach } from "vitest"
|
||||||
|
|
||||||
|
import { hasSession, loadSession, signIn, signOut } from "./session"
|
||||||
|
|
||||||
|
describe("session", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("starts unauthenticated", () => {
|
||||||
|
expect(loadSession()).toBeNull()
|
||||||
|
expect(hasSession()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects empty credentials", async () => {
|
||||||
|
await expect(signIn("", "")).rejects.toThrow(/required/i)
|
||||||
|
await expect(signIn("not-an-email", "pw")).rejects.toThrow(/valid email/i)
|
||||||
|
expect(hasSession()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("creates a session on sign-in and clears on sign-out", async () => {
|
||||||
|
const session = await signIn("alice@example.com", "hunter2")
|
||||||
|
expect(session.email).toBe("alice@example.com")
|
||||||
|
expect(session.token).toMatch(/^dev-/)
|
||||||
|
expect(hasSession()).toBe(true)
|
||||||
|
|
||||||
|
signOut()
|
||||||
|
expect(loadSession()).toBeNull()
|
||||||
|
expect(hasSession()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
129
app/lib/session.ts
Normal file
129
app/lib/session.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// Session — minimal auth scaffold backed by localStorage.
|
||||||
|
// Swap loadSession/signIn/signOut for real calls (cookies + server) when you
|
||||||
|
// wire a backend. The shape here matches what AppShell + useUser expect.
|
||||||
|
|
||||||
|
import { useEffect, useSyncExternalStore } from "react"
|
||||||
|
|
||||||
|
import { profileInitials } from "~/lib/profile"
|
||||||
|
|
||||||
|
export type Session = {
|
||||||
|
userId: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
token: string
|
||||||
|
// Issued at, ms since epoch.
|
||||||
|
issuedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "crema.session"
|
||||||
|
const CHANGE_EVENT = "crema:session-change"
|
||||||
|
|
||||||
|
function readFromStorage(): Session | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw) as Partial<Session>
|
||||||
|
if (
|
||||||
|
typeof parsed.userId !== "string" ||
|
||||||
|
typeof parsed.email !== "string" ||
|
||||||
|
typeof parsed.token !== "string"
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
return {
|
||||||
|
userId: parsed.userId,
|
||||||
|
name:
|
||||||
|
typeof parsed.name === "string" && parsed.name.trim()
|
||||||
|
? parsed.name
|
||||||
|
: parsed.email,
|
||||||
|
email: parsed.email,
|
||||||
|
token: parsed.token,
|
||||||
|
issuedAt:
|
||||||
|
typeof parsed.issuedAt === "number" ? parsed.issuedAt : Date.now(),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSession(): Session | null {
|
||||||
|
return readFromStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock sign-in. Validates only that email + password are non-empty; returns
|
||||||
|
* a fake session. Replace with a real fetch to your auth endpoint.
|
||||||
|
*/
|
||||||
|
export async function signIn(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<Session> {
|
||||||
|
await new Promise((r) => setTimeout(r, 250))
|
||||||
|
if (!email.trim() || !password.trim()) {
|
||||||
|
throw new Error("Email and password are required.")
|
||||||
|
}
|
||||||
|
if (!email.includes("@")) {
|
||||||
|
throw new Error("Enter a valid email address.")
|
||||||
|
}
|
||||||
|
const session: Session = {
|
||||||
|
userId: `u-${Date.now().toString(36)}`,
|
||||||
|
name: email.split("@")[0].replace(/\W/g, " ").trim() || email,
|
||||||
|
email,
|
||||||
|
token: `dev-${Math.random().toString(36).slice(2, 14)}`,
|
||||||
|
issuedAt: Date.now(),
|
||||||
|
}
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
|
||||||
|
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signOut() {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if a non-expired session is in storage. */
|
||||||
|
export function hasSession(): boolean {
|
||||||
|
return !!readFromStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: Session | null = null
|
||||||
|
let cacheValid = false
|
||||||
|
|
||||||
|
function subscribe(cb: () => void): () => void {
|
||||||
|
const onChange = () => {
|
||||||
|
cacheValid = false
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
window.addEventListener(CHANGE_EVENT, onChange)
|
||||||
|
window.addEventListener("storage", (e) => {
|
||||||
|
if (e.key === STORAGE_KEY) onChange()
|
||||||
|
})
|
||||||
|
return () => window.removeEventListener(CHANGE_EVENT, onChange)
|
||||||
|
}
|
||||||
|
function getSnapshot(): Session | null {
|
||||||
|
if (!cacheValid) {
|
||||||
|
cached = readFromStorage()
|
||||||
|
cacheValid = true
|
||||||
|
}
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
function getServerSnapshot(): Session | null {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession(): Session | null {
|
||||||
|
const s = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||||
|
useEffect(() => {
|
||||||
|
cacheValid = false
|
||||||
|
}, [])
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionInitials(session: Session | null): string {
|
||||||
|
if (!session) return "?"
|
||||||
|
return profileInitials(session.name || session.email)
|
||||||
|
}
|
||||||
@@ -3,7 +3,12 @@
|
|||||||
|
|
||||||
import { useEffect, useSyncExternalStore } from "react"
|
import { useEffect, useSyncExternalStore } from "react"
|
||||||
|
|
||||||
export type ThreadMessage = { role: "user" | "assistant"; content: string }
|
export type ThreadMessage = {
|
||||||
|
role: "user" | "assistant"
|
||||||
|
content: string
|
||||||
|
/** Persona that authored this assistant message (omitted for user msgs). */
|
||||||
|
agentId?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type Thread = {
|
export type Thread = {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ export default [
|
|||||||
route("library", "routes/library.tsx"),
|
route("library", "routes/library.tsx"),
|
||||||
route("settings", "routes/settings.tsx"),
|
route("settings", "routes/settings.tsx"),
|
||||||
route("profile", "routes/profile.tsx"),
|
route("profile", "routes/profile.tsx"),
|
||||||
|
route("login", "routes/login.tsx"),
|
||||||
// CREMA:ROUTES
|
// CREMA:ROUTES
|
||||||
] satisfies RouteConfig
|
] satisfies RouteConfig
|
||||||
|
|||||||
@@ -53,6 +53,16 @@ Agents settings: settings-agent-new, settings-agent-reset, settings-agent-activa
|
|||||||
Assistant agent picker: assistant-agent (dropdown — click to switch persona)
|
Assistant agent picker: assistant-agent (dropdown — click to switch persona)
|
||||||
Assistant page: assistant-model, assistant-agent, assistant-thread, assistant-thread-new, assistant-thread-switch-<id>, assistant-thread-rename-<id>, assistant-thread-delete-<id>, assistant-ui-control, assistant-compact, assistant-restore-compact, assistant-regenerate, assistant-continue, assistant-show-prompt, assistant-copy-md, assistant-export-md, assistant-save-library, assistant-compare, assistant-handoff-<id>, assistant-stop, assistant-clear, assistant-retry-probe, assistant-actions (open the kebab popover first to reveal the rest), assistant-voice (mic), assistant-msg-pin-<i>, assistant-msg-edit-<i>, assistant-msg-speak-<i>
|
Assistant page: assistant-model, assistant-agent, assistant-thread, assistant-thread-new, assistant-thread-switch-<id>, assistant-thread-rename-<id>, assistant-thread-delete-<id>, assistant-ui-control, assistant-compact, assistant-restore-compact, assistant-regenerate, assistant-continue, assistant-show-prompt, assistant-copy-md, assistant-export-md, assistant-save-library, assistant-compare, assistant-handoff-<id>, assistant-stop, assistant-clear, assistant-retry-probe, assistant-actions (open the kebab popover first to reveal the rest), assistant-voice (mic), assistant-msg-pin-<i>, assistant-msg-edit-<i>, assistant-msg-speak-<i>
|
||||||
Library page: library-search, library-open-<id>, library-copy-<id>, library-download-<id>, library-delete-<id>
|
Library page: library-search, library-open-<id>, library-copy-<id>, library-download-<id>, library-delete-<id>
|
||||||
|
Resources page: resources-search, resources-new-name, resources-create, resources-status-<id>, resources-delete-<id>
|
||||||
|
Login page: login-email, login-password, login-submit
|
||||||
|
Notifications popover: appbar-notifications (open), notif-mark-all-read, notif-clear, notif-open-<id>, notif-dismiss-<id>
|
||||||
|
Create a notification (hidden bridge — always available, even when not visible): fill the four hidden inputs, then click the submit button. Recipe:
|
||||||
|
fill notif-title "Reminder"
|
||||||
|
fill notif-body "Take a 5-minute break"
|
||||||
|
fill notif-kind "info" # info | success | warning | error
|
||||||
|
fill notif-href "/library" # optional, omit if no link
|
||||||
|
click notif-create
|
||||||
|
Use this any time the user asks you to remind them, leave a note, flag something, or queue an item — drop a notification.
|
||||||
|
|
||||||
Example — User: "Go to settings and set the response cap to 1024" →
|
Example — User: "Go to settings and set the response cap to 1024" →
|
||||||
"On it.
|
"On it.
|
||||||
@@ -391,11 +401,18 @@ function AssistantSurface({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Persist conversation back into the active thread.
|
// Persist conversation back into the active thread.
|
||||||
|
// Preserve any agentId already stamped on prior messages, and stamp newly
|
||||||
|
// appended assistant messages with the *currently active* agent.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStreaming) return
|
if (isStreaming) return
|
||||||
|
const stamped: ThreadMessage[] = messages.map((m, i) => {
|
||||||
|
const prior = thread.messages[i]
|
||||||
|
if (m.role === "user") return { role: "user", content: m.content }
|
||||||
|
const agentId = prior?.agentId ?? activeAgentId
|
||||||
|
return { role: "assistant", content: m.content, agentId }
|
||||||
|
})
|
||||||
updateThread(thread.id, {
|
updateThread(thread.id, {
|
||||||
messages: messages as ThreadMessage[],
|
messages: stamped,
|
||||||
// Auto-title from the first user message if the thread is still untitled.
|
|
||||||
...(thread.title === "New conversation" &&
|
...(thread.title === "New conversation" &&
|
||||||
messages[0]?.role === "user"
|
messages[0]?.role === "user"
|
||||||
? { title: deriveTitleFromFirstMessage(messages[0].content) }
|
? { title: deriveTitleFromFirstMessage(messages[0].content) }
|
||||||
@@ -667,9 +684,20 @@ function AssistantSurface({
|
|||||||
{ maxTokens: 220 },
|
{ maxTokens: 220 },
|
||||||
)
|
)
|
||||||
const note = `🤝 **Handoff: ${activeAgent.name} → ${target.name}**\n\n${briefing.trim()}`
|
const note = `🤝 **Handoff: ${activeAgent.name} → ${target.name}**\n\n${briefing.trim()}`
|
||||||
|
// Stamp the existing thread messages (preserve their authorship) and
|
||||||
|
// attribute the handoff note itself to the OUTGOING agent.
|
||||||
|
const stamped: ThreadMessage[] = messages.map((m, i) => {
|
||||||
|
const prior = thread.messages[i]
|
||||||
|
if (m.role === "user") return { role: "user", content: m.content }
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content: m.content,
|
||||||
|
agentId: prior?.agentId ?? activeAgent.id,
|
||||||
|
}
|
||||||
|
})
|
||||||
const next: ThreadMessage[] = [
|
const next: ThreadMessage[] = [
|
||||||
...(messages as ThreadMessage[]),
|
...stamped,
|
||||||
{ role: "assistant", content: note },
|
{ role: "assistant", content: note, agentId: activeAgent.id },
|
||||||
]
|
]
|
||||||
updateThread(thread.id, { messages: next, agentId: target.id })
|
updateThread(thread.id, { messages: next, agentId: target.id })
|
||||||
saveActiveAgentId(target.id)
|
saveActiveAgentId(target.id)
|
||||||
@@ -832,14 +860,37 @@ function AssistantSurface({
|
|||||||
!messages.slice(i + 1).some((x) => x.role === "user")
|
!messages.slice(i + 1).some((x) => x.role === "user")
|
||||||
const isEditing = editingIndex === i
|
const isEditing = editingIndex === i
|
||||||
const isUser = m.role === "user"
|
const isUser = m.role === "user"
|
||||||
|
const msgAgentId =
|
||||||
|
!isUser
|
||||||
|
? thread.messages[i]?.agentId ?? activeAgent?.id
|
||||||
|
: undefined
|
||||||
|
const msgAgent = msgAgentId
|
||||||
|
? agents.find((a) => a.id === msgAgentId) ?? activeAgent
|
||||||
|
: undefined
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={
|
className={
|
||||||
"group flex flex-col " +
|
"group flex w-full items-start gap-2 " +
|
||||||
(isUser ? "items-end" : "items-start")
|
(isUser ? "flex-row-reverse" : "flex-row")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{!isUser && msgAgent && (
|
||||||
|
<Avatar
|
||||||
|
className="mt-5 size-7 shrink-0 ring-1 ring-border"
|
||||||
|
title={`${msgAgent.name} — ${msgAgent.role}`}
|
||||||
|
>
|
||||||
|
<AvatarFallback
|
||||||
|
style={{
|
||||||
|
background: agentTint(msgAgent.id),
|
||||||
|
color: "var(--primary-foreground)",
|
||||||
|
}}
|
||||||
|
className="text-[11px] font-semibold"
|
||||||
|
>
|
||||||
|
{agentInitials(msgAgent.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"flex max-w-[80%] flex-col " +
|
"flex max-w-[80%] flex-col " +
|
||||||
@@ -848,8 +899,15 @@ function AssistantSurface({
|
|||||||
>
|
>
|
||||||
<div className="mb-0.5 flex items-center gap-1.5 px-1">
|
<div className="mb-0.5 flex items-center gap-1.5 px-1">
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
{isUser ? "You" : "Assistant"}
|
{isUser
|
||||||
|
? "You"
|
||||||
|
: (msgAgent?.name ?? "Assistant")}
|
||||||
</span>
|
</span>
|
||||||
|
{!isUser && msgAgent && (
|
||||||
|
<span className="text-[10px] text-muted-foreground/70">
|
||||||
|
· {msgAgent.role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isPinned && (
|
{isPinned && (
|
||||||
<Pin className="size-3 fill-primary text-primary" />
|
<Pin className="size-3 fill-primary text-primary" />
|
||||||
)}
|
)}
|
||||||
@@ -1368,10 +1426,24 @@ function AssistantSurface({
|
|||||||
responseBudget={responseBudget}
|
responseBudget={responseBudget}
|
||||||
onClose={() => setCompareOpen(false)}
|
onClose={() => setCompareOpen(false)}
|
||||||
onAppend={(agentName, content) => {
|
onAppend={(agentName, content) => {
|
||||||
|
const speaker = agents.find((a) => a.name === agentName)
|
||||||
const note = `🪞 **${agentName} says:**\n\n${content}`
|
const note = `🪞 **${agentName} says:**\n\n${content}`
|
||||||
|
const stamped: ThreadMessage[] = messages.map((m, i) => {
|
||||||
|
const prior = thread.messages[i]
|
||||||
|
if (m.role === "user") return { role: "user", content: m.content }
|
||||||
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content: m.content,
|
||||||
|
agentId: prior?.agentId ?? activeAgent?.id,
|
||||||
|
}
|
||||||
|
})
|
||||||
const next: ThreadMessage[] = [
|
const next: ThreadMessage[] = [
|
||||||
...(messages as ThreadMessage[]),
|
...stamped,
|
||||||
{ role: "assistant", content: note },
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: note,
|
||||||
|
agentId: speaker?.id ?? activeAgent?.id,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
updateThread(thread.id, { messages: next })
|
updateThread(thread.id, { messages: next })
|
||||||
setActionLog(`Appended ${agentName}'s reply to the thread.`)
|
setActionLog(`Appended ${agentName}'s reply to the thread.`)
|
||||||
|
|||||||
117
app/routes/login.tsx
Normal file
117
app/routes/login.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useNavigate, useSearchParams } from "react-router"
|
||||||
|
import { Loader2, LogIn, Sparkles } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import { useBrand } from "~/lib/identity"
|
||||||
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
|
import { signIn, useSession } from "~/lib/session"
|
||||||
|
|
||||||
|
export const meta = () => pageTitle("Sign in")
|
||||||
|
|
||||||
|
export default function LoginRoute() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [params] = useSearchParams()
|
||||||
|
const session = useSession()
|
||||||
|
const brand = useBrand()
|
||||||
|
const BrandIcon = brand.icon
|
||||||
|
|
||||||
|
const next = params.get("next") || "/"
|
||||||
|
const [email, setEmail] = useState("you@example.com")
|
||||||
|
const [password, setPassword] = useState("hunter2")
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Already signed in? Bounce.
|
||||||
|
useEffect(() => {
|
||||||
|
if (session) navigate(next, { replace: true })
|
||||||
|
}, [session, next, navigate])
|
||||||
|
|
||||||
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await signIn(email, password)
|
||||||
|
navigate(next, { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Sign-in failed.")
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative isolate flex min-h-svh items-center justify-center bg-background p-4">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader className="items-center text-center">
|
||||||
|
<div className="mb-2 flex size-10 items-center justify-center rounded-xl bg-primary text-primary-foreground">
|
||||||
|
<BrandIcon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>Sign in to {brand.name}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Mock auth — any email + non-empty password works. Wire{" "}
|
||||||
|
<code className="font-mono text-xs">~/lib/session.ts</code> to your
|
||||||
|
real backend.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-sm font-medium">Email</span>
|
||||||
|
<Input
|
||||||
|
data-action="login-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-sm font-medium">Password</span>
|
||||||
|
<Input
|
||||||
|
data-action="login-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-md border border-destructive/50 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
data-action="login-submit"
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="mt-1"
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<LogIn className="size-4" />
|
||||||
|
)}
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
<p className="mt-1 text-center text-xs text-muted-foreground">
|
||||||
|
<Sparkles className="mr-1 inline size-3" />
|
||||||
|
No account needed in dev — credentials aren't checked.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Boxes } from "lucide-react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { Plus, Search, Trash2 } from "lucide-react"
|
||||||
|
|
||||||
import { AppShell } from "~/components/layout/app-shell"
|
import { AppShell } from "~/components/layout/app-shell"
|
||||||
|
import { Button } from "~/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -8,35 +10,172 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card"
|
} from "~/components/ui/card"
|
||||||
|
import { Input } from "~/components/ui/input"
|
||||||
|
import {
|
||||||
|
createResource,
|
||||||
|
deleteResource,
|
||||||
|
seedResourcesIfEmpty,
|
||||||
|
updateResource,
|
||||||
|
useResources,
|
||||||
|
type Resource,
|
||||||
|
} from "~/lib/resources"
|
||||||
import { pageTitle } from "~/lib/page-meta"
|
import { pageTitle } from "~/lib/page-meta"
|
||||||
|
|
||||||
export const meta = () => pageTitle("Resources")
|
export const meta = () => pageTitle("Resources")
|
||||||
|
|
||||||
|
const statuses: Resource["status"][] = ["active", "paused", "archived"]
|
||||||
|
|
||||||
export default function ResourcesRoute() {
|
export default function ResourcesRoute() {
|
||||||
|
const items = useResources()
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [draftName, setDraftName] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
seedResourcesIfEmpty()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
return q
|
||||||
|
? items.filter(
|
||||||
|
(r) =>
|
||||||
|
r.name.toLowerCase().includes(q) ||
|
||||||
|
r.owner.toLowerCase().includes(q) ||
|
||||||
|
r.status.includes(q),
|
||||||
|
)
|
||||||
|
: items
|
||||||
|
}, [items, query])
|
||||||
|
|
||||||
|
const create = () => {
|
||||||
|
const name = draftName.trim()
|
||||||
|
if (!name) return
|
||||||
|
createResource({ name, owner: "You" })
|
||||||
|
setDraftName("")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell title="Resources">
|
<AppShell title="Resources">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Resources</CardTitle>
|
<CardTitle>Resources</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
A list/detail surface for the entities your app manages.
|
Example domain entity. CRUD goes through{" "}
|
||||||
|
<code className="font-mono text-xs">~/lib/resources.ts</code> —
|
||||||
|
swap that file's calls for{" "}
|
||||||
|
<code className="font-mono text-xs">api.get/post/put/del</code>{" "}
|
||||||
|
from <code className="font-mono text-xs">~/lib/api.ts</code> when
|
||||||
|
you have a backend.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex flex-col gap-4">
|
||||||
<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 flex-wrap items-center gap-2">
|
||||||
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground">
|
<div className="relative flex-1 min-w-48">
|
||||||
<Boxes className="size-6" />
|
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
</div>
|
<Input
|
||||||
<div className="max-w-md">
|
data-action="resources-search"
|
||||||
<p className="font-medium">No resources yet</p>
|
value={query}
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
This route is the canonical "traditional" surface. Drop in{" "}
|
placeholder="Search name, owner, status…"
|
||||||
<code className="font-mono text-xs">@crema/table-ui</code> or{" "}
|
className="pl-8"
|
||||||
<code className="font-mono text-xs">@crema/data-ui</code> when
|
/>
|
||||||
you have data to show.
|
|
||||||
</p>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border bg-card/40">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Name</th>
|
||||||
|
<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>
|
||||||
|
<th className="w-10 px-3 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={5}
|
||||||
|
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>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{items.length} total · {filtered.length} shown
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
1087
package-lock.json
generated
1087
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@
|
|||||||
"dev": "react-router dev",
|
"dev": "react-router dev",
|
||||||
"start": "react-router-serve ./build/server/index.js",
|
"start": "react-router-serve ./build/server/index.js",
|
||||||
"typecheck": "react-router typegen && tsc",
|
"typecheck": "react-router typegen && tsc",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"sync-libs": "node scripts/sync-libs.mjs"
|
"sync-libs": "node scripts/sync-libs.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -33,15 +35,19 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "7.13.1",
|
"@react-router/dev": "7.13.1",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/d3-geo": "^3.1.0",
|
"@types/d3-geo": "^3.1.0",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/topojson-client": "^3.1.5",
|
"@types/topojson-client": "^3.1.5",
|
||||||
"@types/topojson-specification": "^1.0.5",
|
"@types/topojson-specification": "^1.0.5",
|
||||||
|
"jsdom": "^29.1.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
vitest.config.ts
Normal file
13
vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "vitest/config"
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tsconfigPaths()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
|
include: ["app/**/*.{test,spec}.{ts,tsx}"],
|
||||||
|
css: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
8
vitest.setup.ts
Normal file
8
vitest.setup.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest"
|
||||||
|
import { afterEach } from "vitest"
|
||||||
|
import { cleanup } from "@testing-library/react"
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
if (typeof localStorage !== "undefined") localStorage.clear()
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user