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:
jules
2026-04-28 15:59:31 +10:00
parent eea5b262cb
commit 3dbf2ac175
16 changed files with 2297 additions and 41 deletions

View File

@@ -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.`)