Files
spoon/docs/superpowers/plans/2026-07-10-spoon-phase5-notifications-polish.md
T
Gabriel Brown 8aa9140191 docs: multi-phase implementation plan (63 tasks across 5 phases)
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.
2026-07-10 15:18:51 -04:00

406 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 14 (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/unit` with `convex-test`. Run `cd packages/backend && bun run test:unit`. The harness pattern is in `packages/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 as `packages/backend/tests/unit/<name>.test.ts`.
- **Next component tests:** jsdom + `@testing-library` in `apps/next/tests/component` (see `render.test.tsx`). Run `cd apps/next && bun run test:component`.
- **Codegen before typecheck:** after any `convex/schema.ts` or function-signature change run `cd packages/backend && bunx convex codegen` so `_generated` is 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)` from `convex/model.ts`. Actions use `getAuthUserId(ctx)` (see `githubSync.ts`).
---
## Task 1: `notifications` + `notificationPreferences` schema
**Files:**
- `packages/backend/convex/schema.ts` (add two tables to `applicationTables`, ~after `threadMessages` / before `ignoredUpstreamChanges`, line ~820)
**Interfaces:**
- **Produces** table `notifications`:
```ts
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):
```ts
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.ts` that inserts a `notifications` row via `t.run(async (ctx) => ctx.db.insert('notifications', {...}))` and reads it back, plus one `notificationPreferences` row. FAIL: tables don't exist (schema validation error).
- [ ] Add both `defineTable` blocks to `applicationTables` in `schema.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 reusing `usesend-js`)
- Reference for UseSend usage: `packages/backend/convex/custom/auth/providers/usesend.ts` (env `USESEND_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 `MutationCtx` from `./_generated/server`, `Id` from `./_generated/dataModel`):
```ts
export 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'>>;
```
Behavior: insert the `notifications` row (`readAt` unset, `createdAt: Date.now()`). Then load the user's `notificationPreferences` (index `by_user`); email is allowed when `emailEnabled !== false` **and** `prefs[EMAIL_PREF_FIELD[kind]] !== false` (unset = true). If allowed and the user has an `email`, `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, body `return await emitNotification(ctx, args);`.
- **Produces** `internalMutation markEmailed({ notificationId })` → patches `emailedAt: 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 })` — runs `getForEmail`; if no `to`, return; else send via UseSend (subject = title, text = body + optional link, minimal HTML mirroring `usesend.ts`); on success `ctx.runMutation(internal.notifications.markEmailed, { notificationId })`. Missing `USESEND_*` env → log and return (do **not** throw; email is best-effort).
**Steps:**
- [ ] In `notifications.test.ts` add failing tests: (a) `emitNotification` inserts an unread row for the user; (b) with `emailEnabled: false` no email is scheduled (assert via a `t.run` count of scheduled functions is impractical — instead assert `emailedAt` stays undefined after `t.finishAllScheduledFunctions()` with UseSend env unset, i.e. row exists and is not emailed); (c) with prefs default (no row) and `USESEND_*` unset, `sendNotificationEmail` runs without throwing and leaves `emailedAt` undefined. FAIL: module doesn't exist.
- [ ] Implement `notifications.ts` (helper + `emit` + `markEmailed` + `getForEmail`) and `notificationsNode.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), call `await emitNotification(ctx, { userId: args.ownerId, kind: 'maintenance_thread', title: args.title, body: args.summary, link: \`/threads/${threadId}\`, spoonId: args.spoonId, threadId })`. (Import `emitNotification` from `./notifications`.)
- `packages/backend/convex/agentJobs.ts` — `updateStatus` (mutation, ~line 1108). Inside the `if (job.threadId)` block, after the thread patch: when `args.status === 'changes_ready'` **or** `'draft_pr_opened'`, emit `kind: 'agent_turn_finished'` (title `Agent turn finished`, body = `args.summary ?? job.summary ?? ''`, link `/threads/${job.threadId}`, threadId, spoonId). Emit only on the transition into that status (guard `job.status !== args.status`).
- `packages/backend/convex/agentJobs.ts` — `applyMaintenanceDecision` (mutation, ~line 1423). When the computed `status === 'waiting_for_user'`, emit `kind: 'agent_needs_input'` (title `Agent needs your input`, body `args.summary`, link `/threads/${job.threadId}`).
- `packages/backend/convex/githubSync.ts` — this is a `'use node'` action, so use `await ctx.runMutation(internal.notifications.emit, {...})`. Two sites:
- Terminal `catch` of `refreshOwnedSpoon` (~line 291, the outer catch that sets `syncStatus: 'error'`): emit `kind: 'sync_failed'` (title `Sync failed for ${spoon.name}`, body `message`, link `/spoons/${spoonId}`, spoonId). `spoon.name` is in scope.
- The maintenance-thread branches already call `createMaintenanceThread` (which now emits) — do **not** double-emit there.
- **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 here` marker at `packages/backend/convex/github.ts` near the connection status writes (~line 181) and emit from the first mutation that patches a connection to `needs_reauth`. Kind `connection_needs_reauth`, title `GitHub connection needs re-authorization`, body `Reconnect 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) calling `createMaintenanceThread` (via `t.run`/internal) for a fresh upstream produces one `maintenance_thread` notification for the owner; a second dedup call does **not** add another. (b) `updateStatus` to `changes_ready` on a claimed job with a thread produces one `agent_turn_finished` notification; 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'>[]` for `getRequiredUserId(ctx)`, index `by_user_created` desc, `take(args.limit ?? 30)`.
- **Produces** `query unreadCount({})` → `number`. Use index `by_user_unread` filtered to `readAt === undefined` (`.withIndex('by_user_unread', q => q.eq('userId', uid).eq('readAt', undefined))`), `.take(100)` then `.length` capped display (return raw count via `.collect().length` bounded by take(100)).
- **Produces** `mutation markRead({ notificationId })` → ensures the row's `userId === getRequiredUserId(ctx)` (else `ConvexError('Notification not found.')`), patch `readAt: Date.now()` if unset.
- **Produces** `mutation markAllRead({})` → iterate `by_user_unread` unread rows for the caller, patch each `readAt`.
**Steps:**
- [ ] Failing tests in `notifications.test.ts`: seed 3 notifications for user A + 1 for user B via `t.run`; `authed(t, A).query(api.notifications.listMine, {})` returns exactly A's 3 newest-first; `unreadCount` returns 3; `markRead` on one drops `unreadCount` to 2 and rejects B's row for A; `markAllRead` zeroes 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 wherever `Controls` is composed — confirm by reading `apps/next/src/components/layout/header/controls/`) — mount `<NotificationBell />` before `<AvatarDropdown />`, only when authenticated (use `useConvexAuth().isAuthenticated`, matching `header/index.tsx`).
**Interfaces:**
- **Consumes** `api.notifications.unreadCount`, `api.notifications.listMine`, `api.notifications.markRead`, `api.notifications.markAllRead`.
- Client component (`'use client'`). Uses `useQuery`/`useMutation` from `convex/react`. Bell icon from `lucide-react` (`Bell`). Wrap in the existing `@spoon/ui` `DropdownMenu` primitives (same set `AvatarDropdown.tsx` imports). Unread badge: small `bg-primary text-primary-foreground` count pill when `unreadCount > 0` (show `9+` above 9). Each item is a `Link` to `notification.link ?? '#'` that calls `markRead({ notificationId })` on click; a "Mark all read" action calls `markAllRead`. Empty state: `No notifications yet.` Relative time via existing date util if present (grep `formatDistanceToNow`/`date-fns` under `apps/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 (follow `render.test.tsx` for the provider/mock pattern) returning `unreadCount: 2` and two notifications; assert the badge shows `2` and both titles render; clicking one invokes the `markRead` mock. 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` (add `getPreferences` query + `updatePreferences` mutation)
- `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 }` — import `Bell` from `lucide-react`; insert after Dotfiles)
**Interfaces:**
- **Produces** `query getPreferences({})` → the caller's `notificationPreferences` row 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), set `updatedAt`.
- 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 a `Switch`/`Checkbox` from `@spoon/ui`; on change call the mutation in a `try/catch` with `toast.success('Preferences saved.')` on resolve and `toast.error(...)` on failure (see Task 8 pattern). Master `emailEnabled` disables the per-kind toggles when off.
**Steps:**
- [ ] Failing backend test in `notifications.test.ts`: `getPreferences` returns all-true defaults with no row; `updatePreferences({ emailSyncFailed: false })` persists and a subsequent `getPreferences` reflects it; the gating in Task 2 respects it (emit `sync_failed` with pref false → no email scheduled / `emailedAt` stays 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 1416: `?? []` on `listMineWithState`, `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 existing `Skeleton` from `@spoon/ui` if present — grep `Skeleton` under `packages/ui`/`apps/next` first).
**Interfaces:**
- **Consumes** existing Convex list queries. Change pattern: keep the raw query result **without** `?? []`, so `undefined` (loading) is distinguishable from `[]` (empty). Render a skeleton block while `data === undefined`; render the real empty-state only when `data.length === 0`.
**Steps:**
- [ ] Component test `apps/next/tests/component/dashboard-loading.test.tsx` (or extend `render.test.tsx`): with the query mock returning `undefined`, 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 `undefined` vs `[]`. Give the skeleton `role="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 172181, 221230).
**Files & exact sites:**
- `apps/next/src/components/settings/dotfiles/dotfiles-manager.tsx` — `saveSelected` (lines 161170: wrap `await putFile(...)` in try/catch; `toast.success('Saved.')` inside try after resolve, `toast.error(...)` in catch) and `deleteSelected` (lines 232239: same around `await removeFile(...)`).
- `apps/next/src/components/integrations/ai-provider-profiles-panel.tsx` (~lines 235240 and 282285: the save + delete mutations).
- `apps/next/src/components/spoons/spoon-clone-panel.tsx` (~lines 225228).
- `apps/next/src/components/spoons/spoon-secrets-form.tsx` (~lines 312315; **and** the sequential import at ~134141 — wrap each iteration so one failure surfaces via `toast.error` and 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 a `putFile` mock that rejects; trigger save; assert `toast.error` called and `toast.success` **not** called. (Mock `sonner`'s `toast`.) 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 unconditional `toast.success(` sits before its `await` in 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 383394): 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 61119: `useState` seeded from the settings query)
- `apps/next/src/components/threads/thread-workspace-form.tsx` (~lines 5774)
**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 via `key={record._id}` on the form in the parent **or** reset `hydrated` when `record._id` changes (`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`: render `spoon-agent-settings-form` first with the settings prop `undefined`, 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 the `finally` on 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 for `signUpForm.reset()` (line 163) and `verifyEmailForm.reset()` (line 182): do not wipe input in `finally`; 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) as `Signing Up...`.
- Typo `Confirm Passsword` (line 426) → `Confirm Password`.
- `apps/next/src/app/(auth)/forgot-password/page.tsx`
- `Please enter the one-time password sent to your phone.` (~line 232233) → `...sent to your email.`
- `Confirm Passsword` typo (~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 the `signIn` mock 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 using `preloadQuery`; `'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 `/profile` route is being deleted in Task 14).
- Lines 64 & 67: empty-string name falls through `??`. Change `user?.name ?? user?.email` and `user.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()}`.
**Steps:**
- [ ] Manual verification (test impractical — server component + dropdown wiring):
- Before: `head -1 apps/next/src/app/(app)/settings/profile/page.tsx` shows `'use server';`. After: first line is `import ...`; `grep -n "use server" apps/next/src/app/(app)/settings/profile/page.tsx` returns nothing.
- Before: `grep -n "href='/profile'" .../AvatarDropdown.tsx` matches. After: it matches `href='/settings/profile'` and no bare `/profile`.
- Before: `grep -n "?? user" .../AvatarDropdown.tsx` matches lines 64/67. After: those use `|| user`.
- [ ] Apply the three edits.
- [ ] `cd apps/next && bun run typecheck` (or `bun run build` if 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 112124: `TableRow role='link'` containing links)
- `apps/next/src/app/(app)/threads/page.tsx` (~lines 317329: `Card role='link'` containing links)
**Interfaces:**
- Pattern: drop the `role='link'` (and any `tabIndex`/`onKeyDown` that simulated link behavior on the container). Make the row/card navigable via a single primary `Link` — either a full-row `Link` wrapping the content, or a "stretched link" overlay (`<Link className="absolute inset-0" ...><span className="sr-only">…</span></Link>` on a `relative` container) 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 has `role="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/app` returns 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** (~124138): 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** (~252265): 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** (~503557): 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]`, ~562 `grid-cols-1`): replace the fixed `min-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/`Sheet` or a toggle) instead of stacking full-height panels.
- `apps/next/src/components/agent-workspace/agent-thread.tsx`
- **Auto-scroll fights the user** (~114132): 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.
- `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 = null` to avoid a stale ref / leak across remounts.
**Steps:**
- [ ] Read each cited range.
- [ ] Component test `apps/next/tests/component/agent-thread-scroll.test.tsx`: simulate the messages list scrolled up (mock `scrollTop`/`scrollHeight`/`clientHeight`), append a new message, assert `scrollTo`/`scrollIntoView` is **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 12 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 1112): remove `'/updates(.*)'`, `'/agents(.*)'`, and `'/profile(.*)'` from `isProtectedRoute` (those routes no longer exist).
- `apps/agent-worker/src/redact.ts` (lines 117): 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]'`. Restructure `secretPatterns` to `{ pattern, replacement }` objects and map accordingly.
- `apps/agent-worker/src/env.ts`: remove `terminalImage` and `terminalIdleMs` (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`; the `api_key=secret` case still yields `api_key=[redacted]`. FAIL (current output has `$1`).
- [ ] Fix `redact.ts` per-pattern replacement; run the worker test (`cd apps/agent-worker && bunx vitest run src/redact.test.ts` or the repo's worker test command) — PASS.
- [ ] Delete the routes/components/empty dirs; trim `proxy.ts`; remove verified-unused `env.ts` vars (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.md`
- `docs/*.md` (enumerate: `ls docs/*.md` and 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`) and `GITHUB_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 docs` returns 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_finished` notification; a maintenance decision needing approval → `agent_needs_input`.
- [ ] Force a sync failure → `sync_failed` notification.
- [ ] 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.ts` no longer emits `$1`.