Shape: My Work — follow-up interaction redesign

resolved-cell: full·dynamic regime: high

Created: 2026-06-15

Branch: main

Worktree: (none — authored on main; /implement should create one via /worktree)

<!-- ← Back to brief (current-plan-brief.md) — the plain-language owner brief; this full plan is for the executing agents -->

PLANNING ONLY. Nothing here is built yet. Build contract for /implement, cross-reference for /verify.
Convergence: authored by a /shape dynamic workflow — 5 code-tracing discovery agents + 4 CRM competitive-research agents + 4 per-sibling ideation agents, then a round-2 adversarial pass (5 design lenses + a fresh independent adjudicator + a completeness critic). The adjudicator's 10 must-fixes and the critic's 11 findings (2 CRITICAL) are all folded below. Adjudicator verdict pre-fold: converged=false; this document is the post-fold revision.

The problem (owner's words, verified in code)

A — Functional, NOT cosmetic. Clicking a follow-up *sometimes loads nothing at all*; some rows/elements are clickable and some are not — inconsistent.

B — Wrong destination. When it does navigate, it "redirects to something" confusing and slow.

C — No clear way to mark a follow-up done. Is the user supposed to "log activity" to record it? That feels unintuitive, and it sits in — and feels like part of — the snooze options.

Root cause (single sentence, code-verified)

hrefFor() (lib/work/actions.ts:159-176) routes every task row to /tasks?task=<id>, but /tasks has no per-task route — the panel only opens if that id is already in the capped 500-row, status/position-ordered board; if it isn't (an overdue follow-up that sorts late, or one filtered out), the deep-link effect (app/tasks/TasksClient.tsx:129-139) silently strips the param and drops the user on the bare board = "nothing happened." Meanwhile activity rows route straight to the entity record (/companies|/people). Same-looking rows, two code paths, one broken — *that is the inconsistency*. Separately, completion is split: the leading circle does a silent status:'done' (records nothing); "Log activity" creates a new activity but leaves the follow-up task open, and it lives inside the snooze menu. The defect is duplicated on the dashboard "Your work" widget (same hrefFor seam).

What good looks like (industry standard — Pipedrive / HubSpot / Salesforce / Close / Things / Todoist / Superhuman)

All seven converge on the same answers (citations in the research appendix):


Cross-sibling build order & file ownership (seam resolution — MANDATORY for /implement)

The adversarial pass found four shared row files were co-owned (two-owners-one-edit). Resolution (the single source of truth — /implement MUST honor it):

| File | Owner | What | Note |

|---|---|---|---|

| lib/work/actions.ts | shared, disjoint hunks | Item 1 rewrites the hrefFor task branch (:160-164); Item 2 ADDS a net-new pure outcomePrefill() helper | Disjoint functions — apply as separate hunks |

| lib/homeQueries.ts | Item 1 | transformTask person-entity emission | NEW to Item 1's scope (CRITICAL #1 fix) |

| contexts/ActivityModalContext.tsx | Item 2 | adds optional sourceTaskId field | Lands FIRST — prerequisite for Item 3's handler cleanup |

| app/work/WorkSnoozeMenu.tsx | Item 3 ONLY | removes "Log activity" + onQuickLog prop | Item 2 only VERIFIES this happened |

| app/work/WorkRow.tsx | shared, sub-region split / strictly serial | circle glyph + busy-inert <Link> = Item 4; onQuickLog prop drop = Item 3; stale-comment deletion = Item 4 (first toucher); the hrefFor VALUE change is in actions.ts, not here | Edit serially to avoid collision |

| app/work/WorkKeyboardShortcuts.tsx | Item 3 | retire the e (log) verb + its cheat-sheet row | Items 2 & 4 VERIFY the copy, do not edit |

| app/work/WorkClient.tsx | shared, coordinated | Item 2 owns the completion success-toast (Undo + Add-outcome), the new openActivityModal({...outcomePrefill, sourceTaskId}) call, and DELETING handleQuickLog/onKbQuickLog; Item 3 drops the onQuickLog pass-through | Single fate: handleQuickLog/onKbQuickLog are deleted; outcome opens via the new call, not the old handler |

| app/work/useWorkActions.ts *(NEW, reserved-shared)* | Item 2 extracts | the optimistic complete/snooze/outcome/Undo lifecycle — today duplicated in WorkClient.runRemoval + HomeDashboard.runWorkRemoval — hoisted into ONE 'use client' hook both surfaces consume (takes the surface's active SWR key as a param: /api/work?scope= for /work, the home-tasks key for the widget; shares the isWorkCacheKey family revalidation + the toast) | The single source of truth for row ACTIONS |

| app/work/WorkRow.tsx (variant) | Item 4 | add variant?: 'full' \| 'compact' so the dashboard widget renders the SAME row component, denser | The single source of truth for row PRESENTATION |

| app/components/home/HomeDashboard.tsx (renderYourWork) | Item 4 | DELETE the hand-rolled row markup + the duplicate runWorkRemoval/handleWork* handlers; render <WorkRow variant="compact"> driven by the shared useWorkActions hook | Single source of truth (owner Decision 5) — the widget is now a *rendering* of the My Work row, not a second implementation |

Build order: (1) Item 2 lands the seam additions (outcomePrefill + sourceTaskId) AND extracts the shared useWorkActions hook from WorkClient.runRemoval → (2) Item 1 (drill-through, independent) → (3) Item 3 (de-conflate, after the context field exists) → (4) Item 4 (legibility/states/Undo, the WorkRow compact variant, and the dashboard single-source refactor — touches WorkRow last). The e-verb retirement threads onQuickLog through 5 files — remove it atomically or tsc breaks.

Single-source-of-truth architecture (owner Decision 5)

The dashboard "Your work" widget today is a second, hand-rolled row implementation (HomeDashboard.renderYourWork, its own markup + its own runWorkRemoval/handleWork* handlers) that merely shares the pure lib/work/actions.ts seam. The owner directive: it must be a differently-rendered view of the My Work panel — one source of truth, not two surfaces kept in parity. The refactor:

Companion-surface decisions (Phase 1d-bis)


Item 1 — Reliable + consistent drill-through (FUNCTIONAL FIX — owner problems A + B)

Description. Make every row click land on the record it names and actually load, identically for tasks and activities. The task branch of hrefFor is rewritten: precedence is (c) sanitizeInternalLink(sourceLink) (converted tasks keep their action-surface deep-link — UNCHANGED, stays first) → (a/b) the row's entity (/companies/<id>?task=<id> | /people/<id>?task=<id> | /projects/<id>?task=<id>) → (d) /tasks?task=<id> only when there is no entity. The entity host reads ?task= and opens the existing TaskDetailPanel in-context (depth-2: row → in-context panel). The /tasks?task= fallback is made self-sufficient — when the id isn't in the loaded board, fetch it by id and open the panel anyway (no more silent strip). One fix in the shared seam heals /work, the dashboard widget, AND keyboard o/Enter.

CRITICAL #1 fold (adjudicator must-fix #1 / critic CRITICAL #1): the new case 'person' branch is dead code today because the work-queue source transformTask (lib/homeQueries.ts:564-578) only ever emits company / project / nullrelated_person_id is consumed as contactName, never as the routing entity. So a "follow up with Jane Doe" (person-only) row gets entity=null → falls to the board → the exact pain B. Fix: add lib/homeQueries.ts to this item and amend transformTask so when company is absent but person is present it emits entity={type:'person', id, name, contactName:null} (fallback order company → project → person → null).

Project-host fold (adjudicator must-fix #3 / critic CRITICAL-adjacent — RESOLVED, Decision 4: full parity). EntityTasksSection has no project scope and is not mounted on the project detail page — routing a project task to /projects/<id>?task=<id> would reintroduce "nothing loads." Fix: give ProjectDetailClient.tsx its own ?task= auto-open effect (it already imports TaskDetailPanel + has handleTaskClick at :590), reading ?task= → find in its board tasks → open, else fetch-by-id → open → strip. Full parity across company/person/project — a project follow-up opens in context on the project (industry standard).

Shape-mapping fold (critic MED #9): the by-id GET /api/tasks/[id] returns a rich shape; the board feeds BoardTask → TaskDetailTask. Pin the field mapping (the GET payload is a superset; pass the full payload, not a 2-field stub) so the fallback can't itself "load nothing." Panel opens over the board as-is; closing returns to the board.

Acceptance.

Files.

Appetite. Small/2-day. One function (hrefFor) + one transformTask amendment + one reusable auto-open effect (copied pattern, applied to 1 shared section + the project host) + one fetch-by-id fallback reusing the existing org-scoped GET /api/tasks/[id]. No new endpoint/RPC/migration/RBAC key. If the project-host effect balloons, fall back to routing project tasks to /tasks?task= (still self-sufficient) and document the divergence.

Permission scope. Rides existing keys only. By-id source is withAuthCached + .eq('organization_id', auth) (org from auth, never the id param). Entity landing pages carry their own companies.view/people.view/projects.view gates. view_only keeps drill-through-only; the corrected target works for them unchanged. No auth_version bump.

Edge cases (KEC).

Parallel-safety. Touches lib/work/actions.ts (disjoint hunk from Item 2) and lib/homeQueries.ts (sole owner). Entity-host edits are file-disjoint from the other items. WorkRow contributes no edit here.

Cohesion verdict. EXTEND — no net-new entity/component. hrefFor extended in place; the in-context open EXTENDS the existing EntityTasksSection/TaskDetailPanel; the by-id source REUSES the existing GET /api/tasks/[id]. Ripple: MUST-PROPAGATE to WorkRow body, dashboard widget, keyboard open (shared seam) + every entity host hrefFor can target (company/person/project). VERIFY-UNAFFECTED: activity drill-through, source_link branch, PPR build, hydration-safety.

Advisory NOTE (downstream, not plan-blocking). hrefFor's switch covers person/project/company(default) — opportunity cannot occur as a task entity (tasks carry company/project/person/null only), so no opportunity case is needed.


Item 2 — Complete-with-outcome (the unified "done" flow — owner problem C)

Description. Replace the split (silent circle + buried "Log activity" that leaves the task open) with ONE coherent model: the leading circle still completes in a single tap (TTFV=0, instant optimistic removal, never a forced wall). That tap fires a success toast that carries two actions: Undo (always) and — for touch/follow-up rows — "Add outcome." "Add outcome" opens the EXISTING activity composer pre-bound to the just-completed row's entity + a sensible verb + subject + the sourceTaskId; on submit the new activity is server-stamped with metadata.task_id so it reads as the outcome of that follow-up on the entity timeline + the TaskDetailPanel Activity tab. The composer's existing Follow-up chip (createTask is_follow_up=true) schedules the next touch — letting the recompute_next_follow_up trigger own the cache (app code writes next_follow_up NOWHERE). Completion stays PATCH /api/tasks/[id] {status:'done'} so the done-transition side effects (automation, webhook, auto system-breadcrumb, trigger) all keep firing.

CRITICAL #2 fold (adjudicator must-fix #2 / critic CRITICAL #2). The "reuses the existing read filter, no new column" claim is only half true: the metadata->>task_id read filter exists, but the user-facing write path strips metadata at three layers (UnifiedActivityModal POST body, lib/activities/validate.ts NormalizedActivityInsert, lib/activities/logInteraction.ts insertRow allowlist). As written the link silently no-ops. Fix — extend the write path: (a) add metadata? to NormalizedActivityInsert + LogInteractionPayload and persist it in logInteraction.insertRow (+ createEvent insert); (b) UnifiedActivityModal includes metadata:{task_id: sourceTaskId} in the POST body when sourceTaskId is set; (c) SECURITY: the activities POST route does NOT trust a client metadata blob — extract only task_id, UUID-validate it, verify it references a task in the caller's org (the assertRelatedFksOwned pattern), and stamp a clean server-side metadata:{task_id}. The column + (metadata->>'task_id') index already exist (migration 20260523330000) → no migration.

Undo fold (critic HIGH #6). Reversible completion is a unanimous table-stake; today success = the row is simply gone. The completion toast gains an Undo action (re-PATCH status to the prior value — which clears completed_at, route :191 — + optimistic re-insert). The API precedent exists (the DELETE path already ships a restoreToken undo, :566). Toast supports action+duration; a second action slot is a small Toast.tsx extension (Undo + Add-outcome). See Decision 1.

Schedule-next fold (critic MED #5). Don't downgrade the strongest research pattern to a passive chip. For touch-type completions the composer's "set a follow-up" is prominently defaulted (Pipedrive/HubSpot "opting out is the deliberate act"); optionally read the entity's next_follow_up to nudge only when there's no next step. See Decision 2.

Disposition fold (critic MED #4). When the outcome verb resolves to call, surface the composer's EXISTING structured call_outcome select (Connected/Voicemail/No Answer/Busy/Wrong Number, :77-84) — closes the structured-disposition table-stake with zero new UI.

Activity-row fold (critic MED #8). Activity rows also flow through handleComplete. Define it explicitly: an activity-row completion gets a plain success toast + Undo, NO "Add outcome" (the activity *is* the record) — a documented, deliberate choice so the two row types aren't an accidental inconsistency.

Verb-default honesty fold (adjudicator must-fix #8). A follow-up TASK carries no activityType, so "default to call" never fires for the headline case. Reword: "defaults the verb to the row's activity type when the row is an activity (call→call, meeting→meeting), and to note for a plain/follow-up task."

Handler-fate fold (adjudicator must-fix #7). Single fate: Item 2 introduces a NEW openActivityModal({...outcomePrefill(item), sourceTaskId}) call in the runRemoval success branch; the +New FAB keeps its own call; the OLD handleQuickLog (quickMode, no sourceTaskId) and onKbQuickLog are DELETED. No dead handler, no second composer-open path.

CSRF fold (critic LOW #11). The composer POST is a raw fetch; when adding the metadata flow, migrate it to mutationFetch (or confirm the CSRF interceptor is installed before the toast action can fire).

Acceptance.

Files.

Appetite. Medium/~2.5 days (grew from the lite estimate because the write-path extension + Undo + dashboard parity are real). Still no new endpoint/RPC/migration; every capability rides an existing home (composer, action-toast, completeRequest, metadata->>task_id, createTask).

Permission scope. Completion = tasks.edit; outcome = activities.create (withPermission, view_only='none'→403); next-step = tasks.create (assertRelatedFksOwned org-verifies FKs). Outcome offer only reachable when the user could complete (!readOnly); API 403s independently.

Edge cases (KEC).

Parallel-safety. lib/work/actions.ts disjoint hunk (vs Item 1). ActivityModalContext.tsx landed FIRST. The activity write-path files (validate.ts/logInteraction.ts/createEvent.ts/route.ts) are this item's exclusive scope. WorkClient coordinated with Item 3 (onQuickLog drop).

Cohesion verdict. EXTEND the existing composer — do NOT fork. Net-new: one pure outcomePrefill helper + one optional sourceTaskId field + a metadata allowlist extension + a second toast action. PARALLEL-JUSTIFIED: none. Ripple: MUST-PROPAGATE to the dashboard widget, the keyboard cheat-sheet/announce copy (via Item 3), the three write-path files + the modal + the POST route. VERIFY-UNAFFECTED: the done-transition side effects (must keep firing on PATCH done), the activity timeline taxonomy/anti-slop (no new outcome hue), the +New FAB compose-then-complete path.

Advisory NOTE. The system breadcrumb auto-written on task-done stays — do NOT stack a second activity insert on the PATCH; the user outcome is the SEPARATE, optional composer activity.


Item 3 — De-conflate complete/log from snooze (row action model)

Description. Make the DO-vs-HAPPENED grammar unambiguous: the leading Complete circle is the single primary "I did this" verb (handing to Item 2's flow), and snooze/reschedule is a clearly-secondary defer family that never co-locates with outcome capture again. "Log activity" is REMOVED from the menu (the structural cause of "logging feels like snoozing"). The becomes a pure defer+delegate menu: Snooze presets + Pick-a-date, then (manager) Reassign. The trailing "Later" hover chip stays. Keyboard e (log) is retired; c/x complete, s opens the defer menu. This item is the single owner of the final row-action grammar (WorkSnoozeMenu, the onQuickLog prop drop on WorkRow, WorkKeyboardShortcuts + its cheat-sheet/announce copy).

Reassign fold (critic HIGH #7 — RESOLVED, Decision 3). handleReassign today calls handleQuickLog → the activity composer, which has no assignee control = a dead manager control. Repoint Reassign to open the now-reliable TaskDetailPanel (which owns the real assignee mutation) instead of the composer — industry standard (owner is a field on the task record), reuses Item 1's fix, no new picker. No dead control ships.

KEC fold (adjudicator must-fix #9). Re-disposition this item's edge axes — three were bare ACCEPT tokens with no backing bullet ("disguised blanks"): set failure-mode, race/concurrency, related-entity/referential to N/A (this item adds no async mutation / no entity relationship — it removes a menu item and re-groups a dropdown), and back boundary/empty/over-limit with a real bullet.

Acceptance.

Files.

Appetite. Small/~1 day, mostly subtractive. Sequence AFTER Item 2 lands sourceTaskId (so the handler cleanup reads the final context shape).

Permission scope. Zero new keys. Reassign stays manager-gated (canReassign = canSeeTeam); the tasks.assign route enforcement applies if wired to a real mutation. view_only unchanged.

Edge cases (KEC).

Parallel-safety. Owns WorkSnoozeMenu, WorkKeyboardShortcuts, WorkSection; shares WorkRow/WorkClient/HomeDashboard under the ownership table (serial). onQuickLog removal is atomic across the 5 files.

Cohesion verdict. EXTEND (subtractive restructure). No net-new. Ripple: MUST-PROPAGATE — the onQuickLog removal + keyboard grammar across the row component cluster. VERIFY-UNAFFECTED: snooze behavior, view_only path.


Item 4 — Affordance discoverability + states (legibility, click feedback, loading/empty/error, a11y, dashboard parity)

Description. Make "this is how you mark it done" unmistakable (the dashed circle currently reads as decorative — the owner didn't realize it completes), kill the "I click and nothing happens" perception with real states, and bring the dashboard "Your work" widget to full parity. Owns: the circle's legibility treatment, the busy-inert <Link> (pointer-events-none + aria-disabled + removed from tab order while a mutation is in flight), the stale-comment deletion on WorkRow, the loading/empty/error/no-op states, keyboard c/o discoverability + the live-region announcements (VERIFYING Item 3's grammar), touch ≥44px, motion-safe transitions, focus management.

Dashboard single-source refactor (owner Decision 5, supersedes critic HIGH #3's "parity" framing). The widget's row is today a separate hand-rolled block (absolutely-positioned hover-gated cluster, date hidden on touch, non-legible resting circle, non-busy-inert <Link>, a duplicate runWorkRemoval). Per the owner directive it must NOT be a second implementation kept "in parity" — it becomes a compact rendering of the SAME WorkRow driven by the shared useWorkActions hook (extracted by Item 2). This item: adds the variant: 'full' | 'compact' prop to WorkRow, and in HomeDashboard.renderYourWork DELETES the bespoke row markup + the duplicate handlers and renders <WorkRow variant="compact"> wired to useWorkActions(homeTasksKey). The widget keeps its own (limited) home-tasks DATA fetch; it owns no row markup or mutation logic anymore. Every Item-1/2/3/4 fix then shows on both surfaces with nothing to propagate. (Implementer guards: WorkRow must not depend on /work-only context; the dashboard supplies toast + activity-modal context.)

DEFER naming fold (adjudicator must-fix #10). This item's related-entity/referential axis is a DEFER — name the surface: "a row whose linked entity was deleted/merged after the queue was fetched — what the legend cue / pressed-tint / busy-inert affordances read when the drill target no longer resolves." Collected under the per-plan "Known edge surfaces" note below.

Keyboard copy fold (adjudicator must-fix #6). This item's acceptance must NOT assert e works. Corrected: "c/x complete, o/Enter open, s snooze-menu, h snooze-tomorrow; e is retired." Item 4 VERIFIES Item 3's grammar copy; it does not edit the keyboard file.

Acceptance.

Files.

Appetite. Small/~1.5 days (grew with the dashboard-parity scope). Touches WorkRow LAST in build order.

Permission scope. No write affordance added; pure legibility/states. view_only unchanged.

Edge cases (KEC).

Parallel-safety. Shares WorkRow/WorkClient/HomeDashboard under the ownership table (serial; Item 4 touches WorkRow last).

Cohesion verdict. EXTEND — legibility/state polish on existing components + widget parity. No net-new entity. Ripple: MUST-PROPAGATE — the dashboard widget reaches parity. VERIFY-UNAFFECTED: skeleton/empty states, keyboard grammar (Item 3 owns), hydration-safety.


Known edge surfaces for /edge-cases (per-plan note)

Standalone /edge-cases auto-consumes this list:

Decisions (RESOLVED by owner — 2026-06-16)

  1. Completion toast = Undo + Add-outcome. ✅ One-tap Undo on every completion (safety net) + an offered Add outcome for touch/follow-up rows. Small second-action slot on the toast component.
  2. "Schedule next follow-up" prominently defaulted. ✅ Pre-suggested for call/email/meeting completions; opting out is the deliberate act (Pipedrive/HubSpot model). *(Owner may flag later if it should be quieter.)*
  3. Reassign → open the task detail panel. ✅ Industry standard = the owner is a field on the task record; repoint the dead Reassign control to open the now-reliable task panel (real assignee control), not the no-op composer.
  4. Project follow-ups → open on the project page (full parity). ✅ Industry standard = a follow-up opens in context on the record it's about; the project detail page gets its own in-context task-open, like company/person.
  5. Dashboard "Your work" widget = ONE SOURCE OF TRUTH with My Work (owner directive — architectural). ✅ NOT a separate implementation kept "in parity." The widget becomes a differently-rendered (compact) view of the same row component + the same actions hook as the My Work panel. The hand-rolled renderYourWork row markup + its duplicate handlers are DELETED. See "Single-source-of-truth architecture" below + Item 4.

Research appendix (competitive standard — citations)

Pipedrive: one-click checkmark with its own "marked as done" timestamp distinct from due date; conditional "schedule next" pop-up; contextual side-panel with linked-record tabs. HubSpot: checkmark complete decoupled from logging; "log first, complete second"; structured call/meeting outcome enum; default-on follow-up-task prompt; task↔record association so the click lands on the named entity. Salesforce: "Log a Call" *is* completion; row-level "Create Follow-Up Task" dropdown; documented inline-complete-on-virtualized-timeline flakiness = the canonical "some rows clickable, some not" failure → one stable always-rendered click target. Close: marking done auto-creates the activity on the timeline; Done/Snooze/Skip as three separate controls; click-into-the-record always. Things/Todoist/Superhuman: one-click reversible complete on a leading control, separate from the body; complete vs schedule are different gestures/edges; clicking the body opens the record. (Full URLs in the workflow transcript.)