Phase 1 Stabilize (13), Phase 2 Runtime unification (12), Phase 3 Dev-box surface (11), Phase 4 Sync correctness (12), Phase 5 Notifications & polish (15), plus index.
36 KiB
Spoon Phase 5 — Notifications & Polish: Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship the one net-new subsystem for Phase 5 — a notifications table with an in-app header bell and preference-gated email via the existing UseSend integration — then sweep the audit's polish findings (#14–#20: loading skeletons, fire-and-forget error handling, form re-sync, auth copy, workspace UX, accessibility, dead code) and rewrite the docs for the box-first model.
Architecture: Notifications are written by a single shared TS helper emitNotification(ctx, {...}) invoked at the existing event points (maintenance-thread creation, job status transitions, sync failure, connection re-auth). The helper inserts an in-app row and, when the user's per-kind email preference allows, schedules a 'use node' internal action that sends through UseSend (usesend-js, already a dependency). The header bell subscribes to a reactive inbox query; mark-read/mark-all-read are mutations. Preferences live in a per-user notificationPreferences row edited from a new Settings → Notifications page. Everything else in the phase is independent, grouped, small fixes to apps/next UI and apps/agent-worker cleanup.
Tech Stack: Convex (notifications table + queries/mutations, UseSend email), Next.js 16 (React 19) UI, agent-worker minor cleanup.
Depends on: Phases 1–4 (notification emit points reference their statuses/events — job status transitions from Phase 1, connection needs_reauth from the Phase 4 webhook); polish tasks are independent and can run in any order after the notifications subsystem.
Global Constraints
- Backend tests:
packages/backend/tests/unitwithconvex-test. Runcd packages/backend && bun run test:unit. The harness pattern is inpackages/backend/tests/unit/harness.test.ts:convexTest(schema, import.meta.glob('../../convex/**/*.*s')),createUser/authed(t, userId)helpers,t.withIdentity({ subject: '<userId>|session', issuer: 'https://convex.test' }). Add new tests aspackages/backend/tests/unit/<name>.test.ts. - Next component tests: jsdom +
@testing-libraryinapps/next/tests/component(seerender.test.tsx). Runcd apps/next && bun run test:component. - Codegen before typecheck: after any
convex/schema.tsor function-signature change runcd packages/backend && bunx convex codegenso_generatedis current, then typecheck. - Conventional commits, one per task (e.g.
feat(notifications): add notifications + notificationPreferences tables). - Theme: use existing semantic tokens only (
text-foreground,bg-card,text-muted-foreground,bg-primary/10, etc.). No hard-coded hex colors. The codebase is already clean here. - Auth in Convex functions: queries/mutations get the caller via
getRequiredUserId(ctx)fromconvex/model.ts. Actions usegetAuthUserId(ctx)(seegithubSync.ts).
Task 1: notifications + notificationPreferences schema
Files:
packages/backend/convex/schema.ts(add two tables toapplicationTables, ~afterthreadMessages/ beforeignoredUpstreamChanges, line ~820)
Interfaces:
- Produces table
notifications:notifications: defineTable({ userId: v.id('users'), kind: v.union( v.literal('maintenance_thread'), v.literal('agent_turn_finished'), v.literal('agent_needs_input'), v.literal('sync_failed'), v.literal('connection_needs_reauth'), ), title: v.string(), body: v.string(), link: v.optional(v.string()), // in-app relative path, e.g. `/threads/<id>` spoonId: v.optional(v.id('spoons')), threadId: v.optional(v.id('threads')), readAt: v.optional(v.number()), emailedAt: v.optional(v.number()), createdAt: v.number(), }) .index('by_user', ['userId']) .index('by_user_unread', ['userId', 'readAt']) .index('by_user_created', ['userId', 'createdAt']), - Produces table
notificationPreferences(one row per user; unset field = default enabled):notificationPreferences: defineTable({ userId: v.id('users'), emailEnabled: v.optional(v.boolean()), // master email switch, default true emailMaintenance: v.optional(v.boolean()), emailAgentTurnFinished: v.optional(v.boolean()), emailAgentNeedsInput: v.optional(v.boolean()), emailSyncFailed: v.optional(v.boolean()), emailConnectionReauth: v.optional(v.boolean()), updatedAt: v.number(), }).index('by_user', ['userId']),
Steps:
- Write failing test
packages/backend/tests/unit/notifications.test.tsthat inserts anotificationsrow viat.run(async (ctx) => ctx.db.insert('notifications', {...}))and reads it back, plus onenotificationPreferencesrow. FAIL: tables don't exist (schema validation error). - Add both
defineTableblocks toapplicationTablesinschema.ts. cd packages/backend && bunx convex codegen.- Run
bun run test:unit— PASS. - Commit:
feat(notifications): add notifications + notificationPreferences tables.
Task 2: emit helper, emit mutation wrapper, and UseSend email dispatch
The single choke point all emit points call. A plain TS helper is callable directly inside mutations; an internalMutation wrapper lets Convex actions (e.g. githubSync) call it via ctx.runMutation.
Files:
packages/backend/convex/notifications.ts(new — helper + emit wrapper + preference read)packages/backend/convex/notificationsNode.ts(new,'use node'— email send action reusingusesend-js)- Reference for UseSend usage:
packages/backend/convex/custom/auth/providers/usesend.ts(envUSESEND_API_KEY,USESEND_URL,USESEND_FROM_EMAIL;new UseSend(apiKey, useSendUrl),useSend.emails.send({from,to,subject,text,html})).
Interfaces:
- Produces exported type + helper (import
MutationCtxfrom./_generated/server,Idfrom./_generated/dataModel):Behavior: insert theexport type NotificationKind = | 'maintenance_thread' | 'agent_turn_finished' | 'agent_needs_input' | 'sync_failed' | 'connection_needs_reauth'; // Maps a kind to the notificationPreferences field that gates its email. const EMAIL_PREF_FIELD: Record<NotificationKind, keyof Doc<'notificationPreferences'>> = { maintenance_thread: 'emailMaintenance', agent_turn_finished: 'emailAgentTurnFinished', agent_needs_input: 'emailAgentNeedsInput', sync_failed: 'emailSyncFailed', connection_needs_reauth: 'emailConnectionReauth', }; export async function emitNotification( ctx: MutationCtx, args: { userId: Id<'users'>; kind: NotificationKind; title: string; body: string; link?: string; spoonId?: Id<'spoons'>; threadId?: Id<'threads'> }, ): Promise<Id<'notifications'>>;notificationsrow (readAtunset,createdAt: Date.now()). Then load the user'snotificationPreferences(indexby_user); email is allowed whenemailEnabled !== falseandprefs[EMAIL_PREF_FIELD[kind]] !== false(unset = true). If allowed and the user has anemail,ctx.scheduler.runAfter(0, internal.notificationsNode.sendNotificationEmail, { notificationId }). Return the id. - Produces
internalMutation emit(thin wrapper for actions) with the same args as the helper, bodyreturn await emitNotification(ctx, args);. - Produces
internalMutation markEmailed({ notificationId })→ patchesemailedAt: Date.now()(called by the email action). - Produces
internalQuery getForEmail({ notificationId })→ returns{ to: string | null; title; body; link }by loading the notification + its user's email. - Produces in
notificationsNode.ts:internalAction sendNotificationEmail({ notificationId })— runsgetForEmail; if noto, return; else send via UseSend (subject = title, text = body + optional link, minimal HTML mirroringusesend.ts); on successctx.runMutation(internal.notifications.markEmailed, { notificationId }). MissingUSESEND_*env → log and return (do not throw; email is best-effort).
Steps:
- In
notifications.test.tsadd failing tests: (a)emitNotificationinserts an unread row for the user; (b) withemailEnabled: falseno email is scheduled (assert via at.runcount of scheduled functions is impractical — instead assertemailedAtstays undefined aftert.finishAllScheduledFunctions()with UseSend env unset, i.e. row exists and is not emailed); (c) with prefs default (no row) andUSESEND_*unset,sendNotificationEmailruns without throwing and leavesemailedAtundefined. FAIL: module doesn't exist. - Implement
notifications.ts(helper +emit+markEmailed+getForEmail) andnotificationsNode.ts(sendNotificationEmail). cd packages/backend && bunx convex codegen.bun run test:unit— PASS.- Commit:
feat(notifications): add emit helper + UseSend email dispatch.
Task 3: wire emit points
Call the helper/mutation at each event the spec lists. All are existing functions.
Files & exact emit points:
packages/backend/convex/threads.ts—createMaintenanceThread(internalMutation, ~line 407). After the new-thread insert (~line 475, not the dedup/existing branch), callawait emitNotification(ctx, { userId: args.ownerId, kind: 'maintenance_thread', title: args.title, body: args.summary, link: \/threads/${threadId}`, spoonId: args.spoonId, threadId }). (ImportemitNotificationfrom./notifications`.)packages/backend/convex/agentJobs.ts—updateStatus(mutation, ~line 1108). Inside theif (job.threadId)block, after the thread patch: whenargs.status === 'changes_ready'or'draft_pr_opened', emitkind: 'agent_turn_finished'(titleAgent turn finished, body =args.summary ?? job.summary ?? '', link/threads/${job.threadId}, threadId, spoonId). Emit only on the transition into that status (guardjob.status !== args.status).packages/backend/convex/agentJobs.ts—applyMaintenanceDecision(mutation, ~line 1423). When the computedstatus === 'waiting_for_user', emitkind: 'agent_needs_input'(titleAgent needs your input, bodyargs.summary, link/threads/${job.threadId}).packages/backend/convex/githubSync.ts— this is a'use node'action, so useawait ctx.runMutation(internal.notifications.emit, {...}). Two sites:- Terminal
catchofrefreshOwnedSpoon(~line 291, the outer catch that setssyncStatus: 'error'): emitkind: 'sync_failed'(titleSync failed for ${spoon.name}, bodymessage, link/spoons/${spoonId}, spoonId).spoon.nameis in scope. - The maintenance-thread branches already call
createMaintenanceThread(which now emits) — do not double-emit there.
- Terminal
- Connection needs re-auth — the Phase 4 webhook sets
gitConnections.status = 'needs_reauth' | 'revoked'. Emit from that mutation (created in Phase 4). If that mutation does not yet exist in this branch, add a// TODO(phase4): emit connection_needs_reauth heremarker atpackages/backend/convex/github.tsnear the connection status writes (~line 181) and emit from the first mutation that patches a connection toneeds_reauth. Kindconnection_needs_reauth, titleGitHub connection needs re-authorization, bodyReconnect your GitHub account to keep syncing., link/settings/integrations.
Interfaces:
- Consumes
emitNotification(mutations) /internal.notifications.emit(actions) from Task 2.
Steps:
- Add failing tests in
notifications.test.ts: (a) callingcreateMaintenanceThread(viat.run/internal) for a fresh upstream produces onemaintenance_threadnotification for the owner; a second dedup call does not add another. (b)updateStatustochanges_readyon a claimed job with a thread produces oneagent_turn_finishednotification; a repeat call at the same status does not add a second. Use the harness helpers to seed a spoon/thread/job like existing agentJobs tests. FAIL: no notifications produced. - Add the emit calls at the five points above (imports as noted).
cd packages/backend && bunx convex codegen;bun run test:unit— PASS.- Commit:
feat(notifications): emit at maintenance/turn/sync/reauth events.
Task 4: inbox query + mark-read mutations
Files:
packages/backend/convex/notifications.ts(add public query/mutations)
Interfaces:
- Produces
query listMine({ limit?: number })→Doc<'notifications'>[]forgetRequiredUserId(ctx), indexby_user_createddesc,take(args.limit ?? 30). - Produces
query unreadCount({})→number. Use indexby_user_unreadfiltered toreadAt === undefined(.withIndex('by_user_unread', q => q.eq('userId', uid).eq('readAt', undefined))),.take(100)then.lengthcapped display (return raw count via.collect().lengthbounded by take(100)). - Produces
mutation markRead({ notificationId })→ ensures the row'suserId === getRequiredUserId(ctx)(elseConvexError('Notification not found.')), patchreadAt: Date.now()if unset. - Produces
mutation markAllRead({})→ iterateby_user_unreadunread rows for the caller, patch eachreadAt.
Steps:
- Failing tests in
notifications.test.ts: seed 3 notifications for user A + 1 for user B viat.run;authed(t, A).query(api.notifications.listMine, {})returns exactly A's 3 newest-first;unreadCountreturns 3;markReadon one dropsunreadCountto 2 and rejects B's row for A;markAllReadzeroes it. FAIL: functions missing. - Implement the query + mutations.
bunx convex codegen;bun run test:unit— PASS.- Commit:
feat(notifications): inbox query + mark-read mutations.
Task 5: header notification bell
Files:
apps/next/src/components/layout/header/controls/notification-bell.tsx(new)apps/next/src/components/layout/header/controls/index.tsx(or whereverControlsis composed — confirm by readingapps/next/src/components/layout/header/controls/) — mount<NotificationBell />before<AvatarDropdown />, only when authenticated (useuseConvexAuth().isAuthenticated, matchingheader/index.tsx).
Interfaces:
- Consumes
api.notifications.unreadCount,api.notifications.listMine,api.notifications.markRead,api.notifications.markAllRead. - Client component (
'use client'). UsesuseQuery/useMutationfromconvex/react. Bell icon fromlucide-react(Bell). Wrap in the existing@spoon/uiDropdownMenuprimitives (same setAvatarDropdown.tsximports). Unread badge: smallbg-primary text-primary-foregroundcount pill whenunreadCount > 0(show9+above 9). Each item is aLinktonotification.link ?? '#'that callsmarkRead({ notificationId })on click; a "Mark all read" action callsmarkAllRead. Empty state:No notifications yet.Relative time via existing date util if present (grepformatDistanceToNow/date-fnsunderapps/next/src); otherwise a minimal inline formatter.
Steps:
- Failing component test
apps/next/tests/component/notification-bell.test.tsx: render<NotificationBell />with a mocked Convex client (followrender.test.tsxfor the provider/mock pattern) returningunreadCount: 2and two notifications; assert the badge shows2and both titles render; clicking one invokes themarkReadmock. FAIL: component missing. - Implement
notification-bell.tsx; mount it in the header controls. cd apps/next && bun run test:component— PASS.- Manual verify:
bun run dev, sign in, confirm the bell renders and the badge updates reactively after seeding a notification. Document the check. - Commit:
feat(notifications): header notification bell.
Task 6: Settings → Notifications preferences
Files:
packages/backend/convex/notifications.ts(addgetPreferencesquery +updatePreferencesmutation)apps/next/src/app/(app)/settings/notifications/page.tsx(new)apps/next/src/components/settings/notification-preferences-panel.tsx(new)apps/next/src/app/(app)/settings/layout.tsx(add nav item{ href: '/settings/notifications', label: 'Notifications', icon: Bell }— importBellfromlucide-react; insert after Dotfiles)
Interfaces:
- Produces
query getPreferences({})→ the caller'snotificationPreferencesrow or a defaults object{ emailEnabled: true, emailMaintenance: true, emailAgentTurnFinished: true, emailAgentNeedsInput: true, emailSyncFailed: true, emailConnectionReauth: true }when no row (unset = enabled). - Produces
mutation updatePreferences({ emailEnabled?, emailMaintenance?, emailAgentTurnFinished?, emailAgentNeedsInput?, emailSyncFailed?, emailConnectionReauth? })→ upsert the caller's row (patch existing or insert), setupdatedAt. - Panel:
'use client',useQuery(api.notifications.getPreferences)+useMutation(api.notifications.updatePreferences). Seed local state from the query using the hydrated-flag pattern (Task 9 reference) so late-resolving prefs don't clobber. Each toggle is aSwitch/Checkboxfrom@spoon/ui; on change call the mutation in atry/catchwithtoast.success('Preferences saved.')on resolve andtoast.error(...)on failure (see Task 8 pattern). MasteremailEnableddisables the per-kind toggles when off.
Steps:
- Failing backend test in
notifications.test.ts:getPreferencesreturns all-true defaults with no row;updatePreferences({ emailSyncFailed: false })persists and a subsequentgetPreferencesreflects it; the gating in Task 2 respects it (emitsync_failedwith pref false → no email scheduled /emailedAtstays unset). FAIL. - Implement
getPreferences+updatePreferences;bunx convex codegen;bun run test:unit— PASS. - Build the page + panel; add the settings nav item.
- Manual verify: toggle a preference, reload, confirm it persists. Document the check.
- Commit:
feat(notifications): settings notification preferences.
Task 7: loading skeletons for dashboard / spoons / threads
Kills the zero-state flash where useQuery(...) ?? [] renders an empty state before data resolves.
Files (read each first for exact useQuery lines):
apps/next/src/app/(app)/dashboard/page.tsx(~lines 14–16:?? []onlistMineWithState,syncRuns.listRecent,threads.listMine)apps/next/src/app/(app)/spoons/page.tsx(~line 35?? [])apps/next/src/app/(app)/threads/page.tsx(?? []on list)- New:
apps/next/src/components/ui/list-skeleton.tsx(or reuse an existingSkeletonfrom@spoon/uiif present — grepSkeletonunderpackages/ui/apps/nextfirst).
Interfaces:
- Consumes existing Convex list queries. Change pattern: keep the raw query result without
?? [], soundefined(loading) is distinguishable from[](empty). Render a skeleton block whiledata === undefined; render the real empty-state only whendata.length === 0.
Steps:
- Component test
apps/next/tests/component/dashboard-loading.test.tsx(or extendrender.test.tsx): with the query mock returningundefined, assert a skeleton (data-testid="list-skeleton"/role="status") is shown and the empty-state copy is not; with[], assert the empty state shows and no skeleton. FAIL. - Add the skeleton component; update the three pages to branch on
undefinedvs[]. Give the skeletonrole="status"+aria-label="Loading". bun run test:component— PASS.- Commit:
fix(ui): loading skeletons for dashboard/spoons/threads.
Task 8: fire-and-forget mutation error handling across panels
Wrap unhandled mutation calls in try/catch; only toast.success after resolve; toast.error on failure. Model on the already-correct importAll/newFile in dotfiles-manager.tsx (lines 172–181, 221–230).
Files & exact sites:
apps/next/src/components/settings/dotfiles/dotfiles-manager.tsx—saveSelected(lines 161–170: wrapawait putFile(...)in try/catch;toast.success('Saved.')inside try after resolve,toast.error(...)in catch) anddeleteSelected(lines 232–239: same aroundawait removeFile(...)).apps/next/src/components/integrations/ai-provider-profiles-panel.tsx(~lines 235–240 and 282–285: the save + delete mutations).apps/next/src/components/spoons/spoon-clone-panel.tsx(~lines 225–228).apps/next/src/components/spoons/spoon-secrets-form.tsx(~lines 312–315; and the sequential import at ~134–141 — wrap each iteration so one failure surfaces viatoast.errorand doesn't silently swallow the rest; report count of successes).
Interfaces: no signature changes; behavior only. Error message: error instanceof Error ? error.message : '<action> failed.'.
Steps:
- Read each file at the cited lines to capture exact
await <mutation>(...)calls. - Component test
apps/next/tests/component/dotfiles-error.test.tsx: render the dotfiles manager with aputFilemock that rejects; trigger save; asserttoast.errorcalled andtoast.successnot called. (Mocksonner'stoast.) FAIL (currently unconditional success). - Apply try/catch + resolve-gated success to all sites above.
bun run test:component— PASS. Manual grep to confirm no remaining unconditionaltoast.success(sits before itsawaitin these files.- Commit:
fix(ui): error-handle fire-and-forget mutations in settings panels.
Task 9: settings/thread forms re-sync on loaded record id
useState seeded once from a late-resolving Convex prop can save defaults over real config. Fix with the hydrated-flag pattern already in dotfiles-manager.tsx RepoPanel (lines 383–394): a hydrated boolean, an effect that seeds from the loaded record then sets hydrated, keyed on the record's _id.
Files:
apps/next/src/components/spoons/spoon-agent-settings-form.tsx(~lines 61–119:useStateseeded from the settings query)apps/next/src/components/threads/thread-workspace-form.tsx(~lines 57–74)
Interfaces:
- Pattern (per form):
const [hydrated, setHydrated] = useState(false);+useEffect(() => { if (!record || hydrated) return; /* seed each useState from record fields */ setHydrated(true); }, [record, hydrated]);. If the mounting parent can swap records, prefer remounting viakey={record._id}on the form in the parent or resethydratedwhenrecord._idchanges (useEffect(() => setHydrated(false), [record?._id])). Choose keyed remount if the parent already has the id; otherwise the id-change effect.
Steps:
- Component test
apps/next/tests/component/form-resync.test.tsx: renderspoon-agent-settings-formfirst with the settings propundefined, then rerender with a resolved record having non-default values; assert the inputs reflect the loaded values (not the initial defaults). FAIL (state seeded once from undefined). - Apply the hydrated-flag (or keyed-remount) pattern to both forms.
bun run test:component— PASS.- Commit:
fix(ui): re-sync settings/thread forms on loaded record.
Task 10: sign-in + forgot-password copy & input-retention fixes
Files & exact fixes:
apps/next/src/app/(auth)/sign-in/page.tsx- Remove
signInForm.reset()from thefinallyon failed sign-in (line 140) — keep the email/password on error. Only reset on success (move a.reset()into the success branch if desired, or drop it). Same forsignUpForm.reset()(line 163) andverifyEmailForm.reset()(line 182): do not wipe input infinally; reset only after a successful transition. pendingText='Signing Up...'on the Verify Email button (line 235) →pendingText='Verifying...'. Leave the actual sign-up button (line 443) asSigning Up....- Typo
Confirm Passsword(line 426) →Confirm Password.
- Remove
apps/next/src/app/(auth)/forgot-password/page.tsxPlease enter the one-time password sent to your phone.(~line 232–233) →...sent to your email.Confirm Passswordtypo (~line 268) →Confirm Password.
Steps:
- Component test
apps/next/tests/component/sign-in-retention.test.tsx: render the sign-in form, type an email + password, make thesignInmock reject, submit; assert the email/password inputs still hold the typed values after the rejection settles. FAIL (reset in finally wipes them). - Apply the input-retention + copy fixes above.
bun run test:component— PASS. Manual grep:grep -rn "Passsword\|sent to your phone\|Signing Up" apps/next/src/app/(auth)returns only the intentional sign-up button.- Commit:
fix(auth): retain input on failure + copy fixes.
Task 11: profile page 'use server' removal + AvatarDropdown fixes
Files:
apps/next/src/app/(app)/settings/profile/page.tsx(line 1: remove the stray'use server';— this is an async server component usingpreloadQuery;'use server'marks it as a Server Actions module and is wrong here).apps/next/src/components/layout/header/controls/AvatarDropdown.tsx- Line 73:
Link href='/profile'→href='/settings/profile'(the/profileroute is being deleted in Task 14). - Lines 64 & 67: empty-string name falls through
??. Changeuser?.name ?? user?.emailanduser.name?.trim() ?? user.email?.trim()to use||so an empty/whitespace name falls back to email:(user?.name?.trim() || user?.email)and label{user.name?.trim() || user.email?.trim()}.
- Line 73:
Steps:
- Manual verification (test impractical — server component + dropdown wiring):
- Before:
head -1 apps/next/src/app/(app)/settings/profile/page.tsxshows'use server';. After: first line isimport ...;grep -n "use server" apps/next/src/app/(app)/settings/profile/page.tsxreturns nothing. - Before:
grep -n "href='/profile'" .../AvatarDropdown.tsxmatches. After: it matcheshref='/settings/profile'and no bare/profile. - Before:
grep -n "?? user" .../AvatarDropdown.tsxmatches lines 64/67. After: those use|| user.
- Before:
- Apply the three edits.
cd apps/next && bun run typecheck(orbun run buildif no dedicated typecheck script) — passes.- Commit:
fix(ui): profile page directive + avatar dropdown links/fallbacks.
Task 12: accessibility — remove role='link' with nested interactive elements
A role='link' container wrapping real <a>/<Link> children is an invalid nested-interactive pattern.
Files:
apps/next/src/app/(app)/spoons/page.tsx(~lines 112–124:TableRow role='link'containing links)apps/next/src/app/(app)/threads/page.tsx(~lines 317–329:Card role='link'containing links)
Interfaces:
- Pattern: drop the
role='link'(and anytabIndex/onKeyDownthat simulated link behavior on the container). Make the row/card navigable via a single primaryLink— either a full-rowLinkwrapping the content, or a "stretched link" overlay (<Link className="absolute inset-0" ...><span className="sr-only">…</span></Link>on arelativecontainer) so nested action links remain individually clickable. Preserve existing hover styles.
Steps:
- Read both cited blocks to capture current structure.
- Component test
apps/next/tests/component/list-a11y.test.tsx: render the spoons table (mock data) and assert no element hasrole="link"and the row exposes an accessible link to the spoon detail. FAIL (role='link' present). - Refactor both to the stretched-link (or single wrapping Link) pattern; remove
role='link'and simulated key handlers. bun run test:component— PASS. Manual grep:grep -rn "role='link'" apps/next/src/appreturns nothing.- Commit:
fix(a11y): remove nested-interactive role='link' on spoon/thread lists.
Task 13: agent workspace UX polish
Group the Phase-5 workspace items. Read each cited range first.
Files & fixes:
apps/next/src/components/agent-workspace/agent-workspace-shell.tsx- Diff/tree load race (~124–138): guard out-of-order responses. Track a per-request sequence number (or
AbortController); ignore a response whose request id is stale so a slower earlier fetch can't overwrite newer data. - Editor buffer not refreshed when agent edits the open file (~252–265): when a workspace change (
agentWorkspaceChanges) arrives for the currently open file: if the buffer is not dirty, reload the file content into the editor; if dirty, set a conflict flag (badge/toast "This file changed on disk") rather than silently overwriting. - Recovery panel over-triggers (~503–557): require N consecutive status/health failures (e.g.
const RECOVERY_THRESHOLD = 3) before showing the recovery panel; reset the counter on any success. One transient error must not trip it. - Mobile layout (~476
min-h-[720px], ~562grid-cols-1): replace the fixedmin-h-[720px]with a viewport-relative min-height (e.g.min-h-[70vh]/min-h-[calc(100dvh-…)]) and ensure the single-column mobile layout collapses the file tree (collapsible/Sheetor a toggle) instead of stacking full-height panels.
- Diff/tree load race (~124–138): guard out-of-order responses. Track a per-request sequence number (or
apps/next/src/components/agent-workspace/agent-thread.tsx- Auto-scroll fights the user (~114–132): only auto-scroll to newest when the user is already near the bottom (compute
scrollHeight - scrollTop - clientHeight < threshold, e.g. 80px). If scrolled up, don't yank; optionally show a "jump to latest" affordance.
- Auto-scroll fights the user (~114–132): only auto-scroll to newest when the user is already near the bottom (compute
apps/next/src/components/agent-workspace/code-editor.tsx- editorRef not cleared on unmount (~53, ~157): in the mount effect's cleanup, dispose the editor and set
editorRef.current = nullto avoid a stale ref / leak across remounts.
- editorRef not cleared on unmount (~53, ~157): in the mount effect's cleanup, dispose the editor and set
Steps:
- Read each cited range.
- Component test
apps/next/tests/component/agent-thread-scroll.test.tsx: simulate the messages list scrolled up (mockscrollTop/scrollHeight/clientHeight), append a new message, assertscrollTo/scrollIntoViewis not called; then with near-bottom, assert it is. FAIL. - Add a unit-style test for the recovery threshold: extract the consecutive-failure decision into a small pure helper (
shouldShowRecovery(consecutiveFailures, threshold)) and test it, or assert via a component test that 1–2 failures don't render the recovery panel and the 3rd does. FAIL. - Implement all fixes above (sequencing guard, dirty-aware buffer refresh, consecutive-failure threshold, mobile min-height + collapsible tree, near-bottom auto-scroll, editorRef cleanup).
bun run test:component— PASS.- Manual verify in
bun run dev: open a workspace, scroll the thread up while the agent streams (no yank), trigger an agent edit of an open clean file (buffer refreshes), resize to mobile width (tree collapses). Document the checks. - Commit:
fix(workspace): scroll/diff-race/recovery/editor/mobile polish.
Task 14: dead code removal + worker redact.ts / env.ts cleanup
Group all removals + the two worker cleanups. Note: redact.ts and env.ts items are Phase-5-scoped per the audit; some env.ts vars may already be gone after Phase 2 — only remove ones that are still present and unreferenced.
Files & actions:
- Delete route dirs:
apps/next/src/app/(app)/updates/,apps/next/src/app/(app)/agents/,apps/next/src/app/(app)/settings/ai/,apps/next/src/app/(auth)/profile/. - Delete component:
apps/next/src/components/landing/tech-stack.tsx(grep first for imports; remove any import/usage — likely in a landing page). - Delete empty dirs:
apps/next/src/components/agents/,apps/next/src/components/updates/(only if empty after the above). apps/next/src/proxy.ts(lines 11–12): remove'/updates(.*)','/agents(.*)', and'/profile(.*)'fromisProtectedRoute(those routes no longer exist).apps/agent-worker/src/redact.ts(lines 1–17): the first three patterns (ghs_…,github_pat_…,sk-…) have no capture group, so the shared'$1=[redacted]'replacement emits a literal$1. Fix: give each pattern its own replacement — group-less patterns replace with'[redacted]'; the(client_secret|…)=(…)pattern keeps'$1=[redacted]'. RestructuresecretPatternsto{ pattern, replacement }objects and map accordingly.apps/agent-worker/src/env.ts: removeterminalImageandterminalIdleMs(and any other audit-listed unused vars —containerAccess,maxConcurrentJobs) only if a repo-wide grep shows no remaining references. Grep before deleting each.
Steps:
- Grep for references to each route/component before deleting:
grep -rn "tech-stack\|/updates\|/agents\|/settings/ai\|(auth)/profile\|href='/profile'" apps/next/src. Confirm nothing (except the AvatarDropdown link already fixed in Task 11) still points at them. - Failing worker test
apps/agent-worker/src/redact.test.ts(vitest):createRedactor([])('token ghs_ABC123 sk-xyz')must contain[redacted]and not contain the literal$1; theapi_key=secretcase still yieldsapi_key=[redacted]. FAIL (current output has$1). - Fix
redact.tsper-pattern replacement; run the worker test (cd apps/agent-worker && bunx vitest run src/redact.test.tsor the repo's worker test command) — PASS. - Delete the routes/components/empty dirs; trim
proxy.ts; remove verified-unusedenv.tsvars (grep each first). cd apps/next && bun run build(or typecheck) passes with no missing-import errors;cd apps/agent-worker && <typecheck>passes.- Manual grep confirms removals:
find apps/next/src/app -type d \( -name updates -o -name agents -o -name ai -o -name profile \)returns nothing under the deleted paths. - Commit:
chore: remove dead routes/components + fix redact/env cleanup.
Task 15: docs rewrite for the box-first model
Files:
README.mddocs/*.md(enumerate:ls docs/*.mdand any relevant subdirs; the spec calls out server-deploy notes)
Interfaces: documentation only. Rewrite the per-job-container narrative to the Phase-2 one long-running per-user box model:
- Each user owns a persistent Fedora container (
spoon-box-*) with a persistent home (homes/<username>/,~/Code), dotfiles, terminal, and agent CLIs; the legacy per-job OpenCode container path is gone. - Three agent runtimes exec into the box: Codex, OpenCode, and Claude Code (note the new Anthropic provider-profile kind).
- GitHub webhooks: document the webhook URL (Convex
httpAction) andGITHUB_APP_WEBHOOK_SECRET; hourly cron remains a fallback. - New env vars: list the box/terminal/notification/webhook env (
SPOON_AGENT_BOX_IDLE_MS,USESEND_API_KEY/USESEND_URL/USESEND_FROM_EMAIL,GITHUB_APP_WEBHOOK_SECRET, Claude Code auth) and drop removed ones (SPOON_AGENT_TERMINAL_IMAGE,SPOON_AGENT_TERMINAL_IDLE_MS, and other Task-14-removed vars). - Add a short Notifications section (in-app bell + email via UseSend, per-user preferences, web push out of scope).
Steps:
ls docs/*.md docs/**/*.md README.md; read each to find per-job-container language (grep -rn "per-job\|spoon-agent-job\|container per\|host_port" README.md docs).- Rewrite the affected sections per the bullets above. No stale references to deleted routes/env.
- Manual verification:
grep -rn "spoon-agent-job\|per-job container" README.md docsreturns nothing; the deploy doc lists the webhook URL +GITHUB_APP_WEBHOOK_SECRET+USESEND_*+ Claude Code; a Notifications section exists. - Commit:
docs: rewrite for box-first model + webhooks + notifications.
Manual verification checklist (end of phase)
- Trigger a maintenance thread (diverged spoon) → in-app bell increments and (with email pref on +
USESEND_*set) an email arrives. - Finish an agent turn (
changes_ready) →agent_turn_finishednotification; a maintenance decision needing approval →agent_needs_input. - Force a sync failure →
sync_failednotification. - Toggle each preference off → the corresponding email stops while the in-app row still appears.
- Mark-read / mark-all-read update the badge reactively.
- Dashboard/spoons/threads show skeletons (no empty-state flash) on cold load.
- Sign-in keeps input on failure; auth copy fixed; forms re-sync from loaded records.
- Deleted routes 404; app builds; worker
redact.tsno longer emits$1.