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:
@@ -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 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>
|
||||
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" →
|
||||
"On it.
|
||||
@@ -391,11 +401,18 @@ function AssistantSurface({
|
||||
})
|
||||
|
||||
// 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(() => {
|
||||
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, {
|
||||
messages: messages as ThreadMessage[],
|
||||
// Auto-title from the first user message if the thread is still untitled.
|
||||
messages: stamped,
|
||||
...(thread.title === "New conversation" &&
|
||||
messages[0]?.role === "user"
|
||||
? { title: deriveTitleFromFirstMessage(messages[0].content) }
|
||||
@@ -667,9 +684,20 @@ function AssistantSurface({
|
||||
{ maxTokens: 220 },
|
||||
)
|
||||
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[] = [
|
||||
...(messages as ThreadMessage[]),
|
||||
{ role: "assistant", content: note },
|
||||
...stamped,
|
||||
{ role: "assistant", content: note, agentId: activeAgent.id },
|
||||
]
|
||||
updateThread(thread.id, { messages: next, agentId: target.id })
|
||||
saveActiveAgentId(target.id)
|
||||
@@ -832,14 +860,37 @@ function AssistantSurface({
|
||||
!messages.slice(i + 1).some((x) => x.role === "user")
|
||||
const isEditing = editingIndex === i
|
||||
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 (
|
||||
<div
|
||||
key={i}
|
||||
className={
|
||||
"group flex flex-col " +
|
||||
(isUser ? "items-end" : "items-start")
|
||||
"group flex w-full items-start gap-2 " +
|
||||
(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
|
||||
className={
|
||||
"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">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{isUser ? "You" : "Assistant"}
|
||||
{isUser
|
||||
? "You"
|
||||
: (msgAgent?.name ?? "Assistant")}
|
||||
</span>
|
||||
{!isUser && msgAgent && (
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
· {msgAgent.role}
|
||||
</span>
|
||||
)}
|
||||
{isPinned && (
|
||||
<Pin className="size-3 fill-primary text-primary" />
|
||||
)}
|
||||
@@ -1368,10 +1426,24 @@ function AssistantSurface({
|
||||
responseBudget={responseBudget}
|
||||
onClose={() => setCompareOpen(false)}
|
||||
onAppend={(agentName, content) => {
|
||||
const speaker = agents.find((a) => a.name === agentName)
|
||||
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[] = [
|
||||
...(messages as ThreadMessage[]),
|
||||
{ role: "assistant", content: note },
|
||||
...stamped,
|
||||
{
|
||||
role: "assistant",
|
||||
content: note,
|
||||
agentId: speaker?.id ?? activeAgent?.id,
|
||||
},
|
||||
]
|
||||
updateThread(thread.id, { messages: next })
|
||||
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 { Button } from "~/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -8,35 +10,172 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} 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"
|
||||
|
||||
export const meta = () => pageTitle("Resources")
|
||||
|
||||
const statuses: Resource["status"][] = ["active", "paused", "archived"]
|
||||
|
||||
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 (
|
||||
<AppShell title="Resources">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resources</CardTitle>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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">
|
||||
<Boxes className="size-6" />
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<p className="font-medium">No resources yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
This route is the canonical "traditional" surface. Drop in{" "}
|
||||
<code className="font-mono text-xs">@crema/table-ui</code> or{" "}
|
||||
<code className="font-mono text-xs">@crema/data-ui</code> when
|
||||
you have data to show.
|
||||
</p>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<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">
|
||||
<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>
|
||||
</Card>
|
||||
</AppShell>
|
||||
|
||||
Reference in New Issue
Block a user