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):
- One consistent click target that lands on the record the row is *about* — identical for every row type, never a generic list.
- One-click, reversible complete — a leading control, an Undo always available (Todoist toast, Superhuman Z, Things re-toggle, Close).
- Completing a touch follow-up captures the outcome — Close auto-creates the activity on completion; Salesforce makes "Log a Call" *be* the completion; HubSpot's "log first, complete second." Optional/offered, never a forced wall, never buried in a defer menu.
- Completing offers the next step — Pipedrive's conditional "schedule next" pop-up, HubSpot's follow-up toggle, Salesforce's "Create Follow-Up Task." "Always have a next step," opting out is the deliberate act.
- Done and Snooze/Reschedule are physically separate controls — never the same button, never the same menu.
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:
- Row presentation = ONE component.
WorkRowgains avariant: 'full' | 'compact'; the widget renders<WorkRow variant="compact">instead of bespoke markup. So every legibility/state/Undo/busy-inert fix lands once and both surfaces show it — there is nothing left to "propagate." - Row actions = ONE hook. The optimistic complete/snooze/outcome/Undo lifecycle (currently duplicated in
WorkClient.runRemovalandHomeDashboard.runWorkRemoval) is hoisted into a new'use client'useWorkActions(activeKey)hook. Both surfaces call it; the only per-surface difference is the active SWR key passed in (the two surfaces fetch from *different* sources —/api/work?scope=vs/api/home/tasks— but both already live in theisWorkCacheKeyfamily, so the shared family-revalidation is identical). The widget keeps its own DATA fetch (limited home-tasks set) but no longer owns row markup or mutation logic. - Net effect: Items 1, 2, 3 stop carrying any "also patch the widget" burden — the widget inherits automatically because it renders the shared component + hook. This is *simpler and safer* than the parity approach the pre-fold plan assumed.
- Implementer guards:
WorkRowmust not depend on/work-only context (the keyboard layer, the/work<SWRConfig>); the widget supplies the toast + activity-modal context (both available inside the authenticated shell).view_only(readOnly) and the compact density are props, not surface-specific branches.
Companion-surface decisions (Phase 1d-bis)
- Access & roles — APPLIES, no new keys. Completion rides
tasks.edit; outcome logging ridesactivities.create; next-step ridestasks.create; reassign rides the existing manager-onlycanReassign = canSeeTeamgate (andtasks.assignif wired to a real mutation).view_onlystays drill-through-only at all three layers (pagereadOnly, WorkRow branch, keyboard early-return) + API 403s independently. Do NOT inventtasks.complete/activities.log(would force a migration +auth_versionbump + editor row). - Tier / limits — N/A. No new gated capability or quota.
- Marketing copy — N/A. Internal UX repair, no public surface.
- AI tools — N/A. No agent/tool surface touched (AI follow-up *creation* already sets
related_company_id/related_person_id; it benefits from the fix for free). - Docs — APPLIES (light). The
/docs/work"My Work" help article gains a short "completing a follow-up & logging the outcome" section. One docs item under Item 2. - Design playbook — APPLIES. All UI work binds
docs/design/APP_REFINEMENT_PLAYBOOK.md+ the/workdesign-of-recorddocs/design/work-surfaces/PLAN.md(disclosure depth ≤2, one cobalt accent per row, control-budget/TTFV clause, anti-AI-slop, touch ≥44px, hydration-safe string-parse dates, reuseSlidePanel/Dropdownprimitives — never a new modal).
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 / null — related_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.
- A follow-up/plain TASK that carries an entity drills to that record (
/companies/<id>|/people/<id>|/projects/<id>) — the SAME mental model as an activity row — never the generic/tasksboard. - A follow-up linked to a PERSON only (
related_person_id, no company/project) drills to/people/<id>?task=<id>— not the board. (transformTasknow emits a person entity; covered by a new test fixture.) - Landing on
/companies/<id>?task=<id>(or person/project) opens the task in-context: the host reads?task=, opensTaskDetailPanelfrom its fetched list, and if the id isn't in that slice, fetchesGET /api/tasks/<id>and opens it anyway, then strips the param. No silent no-op. - A converted task with a valid
source_linkstill drills to its sanitized action surface (sanitizeInternalLinkprecedence stays FIRST) — UNCHANGED. - A task with NO entity routes to
/tasks?task=<id>, andTasksClientopens it even when absent from the 500-row board (fetch-by-id fallback); on a real 404 it toasts "Task not found" instead of dropping the user on a bare board. - The by-id
GET /api/tasks/<id>payload maps cleanly ontoTaskDetailTask(full payload passed, field-name parity verified) so the fallback reliably renders. - Activity rows still route to their entity page exactly as before — no regression (asserted via the extended
actions.test.ts). - The fix lands in
lib/work/actions.hrefForso/work, the dashboard "Your work" widget, AND keyboardo/Enter drill consistently — verified on all three. - The entity-host
?task=auto-open usesuseSearchParams()inside the existing page<Suspense>(noforce-dynamic, nonext/dynamic ssr:true);TaskDetailPanelstaysdynamic ssr:false. Build stays PPR-clean. GET /api/tasks/[id]enforces.eq('organization_id', auth)and 404s a cross-tenant/foreign id — the panel never opens another tenant's task.
Files.
lib/work/actions.ts*(reserved-shared)* —hrefFortask branch: precedence sourceLink → entity (company/person/project) →/tasks?task=. Disjoint from Item 2's hunk.lib/homeQueries.ts*(reserved-shared)* —transformTaskemits apersonentity when company absent + person present. (CRITICAL #1)components/tasks/EntityTasksSection.tsx*(reserved-shared)* — net-new?task=auto-open effect (mirror the provenTasksClientpattern: open from fetched list, else fetch by id, then strip).app/tasks/TasksClient.tsx*(owned)* — deep-link effect (:129-139) gains fetch-by-id fallback + 404 toast + theGET → TaskDetailTaskshape mapping.app/projects/[id]/sections/ProjectDetailClient.tsx*(owned)* — own?task=auto-open effect (project parity). (must-fix #3)app/companies/[id]/sections/CompanyDetailClient.tsx,app/people/[id]/page.tsx*(owned, likely VERIFY-only)* — confirmEntityTasksSectionmounts inside the page<Suspense>so the newuseSearchParamsis PPR-safe; they may need no edit (the effect self-reads inside the section).app/components/home/HomeDashboard.tsx— inherits the correctedhrefForautomatically once Item 4 renders the widget through the sharedWorkRow(single source, Decision 5); no separate Item-1 edit.lib/work/__tests__/actions.test.ts*(reserved-shared)* — extend: follow-up-with-company, follow-up-with-person-only, sourceLink precedence, no-entity fallback, activity unchanged.
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).
- *boundary/empty/over-limit* — ACCEPT. Beyond-500-cap & filtered-out tasks: cases a/b/c bypass the board; case d gains fetch-by-id.
entity=null→/tasks?task=. Missingitem.id→ guard returns/tasks. - *failure-mode* — ACCEPT. By-id 404/500 (deleted/foreign/archived) → "Task not found" toast + strip, never a wedged blank.
- *race/concurrency* — ACCEPT. While busy, the body
<Link>is inert (owned by Item 4) so a click can't race the optimistic removal;?task=strips immediately after opening so back-nav won't reopen a stale panel. - *permission/role* — ACCEPT.
view_onlydrill-through unchanged; by-id source org-scoped; destination page re-checks its own*.view. - *related-entity/referential* — ACCEPT.
item.entitypopulated bytransformTaskfrom org-verified FKs; deleted related entity → destination page 404s its own way (strictly better than the old board dead-end). - *input-validity* — ACCEPT.
source_linkstays routed throughsanitizeInternalLink(rejects external/poisoned);?task=is a server-generated id used only for the org-scoped by-id GET;entity.typeis a known enum.
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.
- One tap on the Complete circle removes the row INSTANTLY (optimistic) for both a follow-up task and an activity row; on failure the row reverts + an error toast — no forced dialog ever blocks completion (TTFV=0).
- Completing a follow-up TASK still issues
PATCH /api/tasks/[id] {status:'done'}so the done-transition side effects fire unchanged (system breadcrumb,task.completedautomation, webhook,recompute_next_follow_up) —next_follow_upwritten by app code NOWHERE; verified the completed follow-up drops from the entity'snext_follow_upMIN. - The COMPLETE success toast carries Undo (re-PATCH to prior status + optimistic re-insert) and, for touch/follow-up rows, Add outcome; snooze/reschedule toasts carry neither (defer ≠ done). The composer is never auto-opened.
- Round-trip (CRITICAL #2): complete a follow-up → Add outcome → submit → the new activity is fetchable via
GET /api/activities?task_id=<id>and renders on theTaskDetailPanelActivity tab + the entity timeline. Thetask_idis server-validated (UUID + org-owned) and stamped server-side; a clientmetadatablob is never trusted wholesale. No migration (column + index already exist). - "Add outcome" pre-binds the composer to the completed row's entity, defaults the verb to the row's activity type for activities (call→call, meeting→meeting) and to note for a plain/follow-up task, and pre-fills a sentence-case subject; when the verb is
callthe existingcall_outcomedisposition select is surfaced. - Scheduling the next follow-up goes through the composer's existing Follow-up chip (
createTask is_follow_up=true) — prominently defaulted for touch completions; the trigger recomputesnext_follow_up; the new follow-up appears aftermutate(isWorkCacheKey)with no manual cache write and no double-count (realtasksrow). - An ACTIVITY-row completion shows a plain success toast + Undo and no "Add outcome" (deliberate, documented).
handleQuickLog/onKbQuickLogare deleted; the only composer-open paths are the post-complete toast action and the +New FAB.- Both
/workAND the dashboard widget show identical completion + outcome-offer behavior (built via the sharedoutcomePrefill+sourceTaskId). view_onlysees no circle → no toast offer; keyboard verbs suppressed after thereadOnlyearly-return;POST /api/activities403s foractivities.create='none'.- The composer POST migrates to
mutationFetch(or the CSRF interceptor is confirmed present) on the path now carryingmetadata. - A short docs section is added to the
/docs/work"My Work" article covering complete → log outcome → schedule next. - All new rendering stays hydration-safe (string-parse, no Date/locale); PPR rules intact (composer stays
dynamic ssr:falseviaGlobalActivityModal).
Files.
app/work/WorkClient.tsx*(shared, coordinated)* — completion success toast (Undo + Add-outcome viaaddToast), the newopenActivityModal({...outcomePrefill, sourceTaskId})call, DELETEhandleQuickLog/onKbQuickLog, activity-row branch (plain toast), announce() copy.runRemovalis extracted into the shareduseWorkActionshook (Decision 5) andWorkClientconsumes it (passing its/api/work?scope=key) rather than owning the lifecycle inline.app/work/useWorkActions.ts*(NEW, reserved-shared — Item 2 extracts)* — the'use client'hook owning the optimistic complete/snooze/outcome/Undo lifecycle + the toast +isWorkCacheKeyfamily revalidation; parameterized by the surface's active SWR key. Both/workand the dashboard widget consume it (single source of truth for row actions).lib/work/actions.ts*(reserved-shared, disjoint hunk)* — net-new pureoutcomePrefill(item): ActivityModalOptions(entity→prefill + verb + subject +sourceTaskId).completeRequestcontract UNCHANGED.contexts/ActivityModalContext.tsx*(reserved-shared — Item 2 owns, lands FIRST)* — add optionalsourceTaskId.components/activity/UnifiedActivityModal.tsx*(reserved-shared)* — on submit, includemetadata:{task_id: sourceTaskId}when set; surfacecall_outcomefor call verb; default Follow-up chip prominently for touch; migrate POST tomutationFetch.lib/activities/validate.ts*(reserved-shared)* —NormalizedActivityInsertgainsmetadata?;validateCreatecopies a validatedmetadata.task_id. (CRITICAL #2)lib/activities/logInteraction.ts*(reserved-shared)* —LogInteractionPayloadgainsmetadata?;insertRowpersists it. (CRITICAL #2)lib/activities/createEvent.ts*(reserved-shared)* — meeting path persistsmetadatatoo.app/api/activities/route.ts*(reserved-shared)* — POST extracts onlytask_id, UUID-validates, org-verifies, stamps clean server-side. (CRITICAL #2 security)components/ui/Toast.tsx*(owned)* — optional second action slot (Undo + Add-outcome). (See Decision 1.)app/components/home/HomeDashboard.tsx— NO separate Item-2 edit: the widget inherits the toast (Undo + Add-outcome) automatically once Item 4 renders it through<WorkRow variant="compact">+useWorkActions(single source of truth, Decision 5). Item 2 only ensures the shared hook carries the toast.app/docs/work/overview/page.tsx*(owned)* — docs section.lib/work/__tests__/actions.test.ts*(reserved-shared)* —outcomePrefillcoverage.
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).
- *boundary/empty* — ACCEPT.
entity=nullcompletion → toast opens composer with no entity prefill (verb+subject+sourceTaskId only); no-due-date row completes identically; toast actions are buttons (no overflow). - *failure-mode* — ACCEPT. Complete PATCH fail → revert + error toast, no outcome offer. Outcome POST fail (row already gone) → composer inline
FormAlert; task stays completed (already succeeded), only the log failed — no half-state.metadata.task_idis best-effort linkage; a missing link degrades to an unlinked-but-valid activity. - *race/concurrency* — ACCEPT.
busyIdsguards double-complete; complete resolves before the toast renders, so PATCH-done-then-POST-activity is sequential (no ordering race). Two tabs → second PATCH idempotent (side effects guarded byexistingTask.status!=='done'). - *permission/role* — ACCEPT.
view_onlyno circle → no offer; rep completing a manager-owned team task gated by the tasks PATCH ownership clamp; outcome ridesactivities.create. - *related-entity/referential* — ACCEPT. Outcome links via
metadata.task_id(JSONB, not FK) so a later task delete can't dangle; next follow-up is a real org-verifiedis_follow_uptask;next_follow_uptrigger-owned, zero app cache writes. - *input-validity* — ACCEPT.
task_idserver-extracted/UUID-validated/org-verified, never trusted from a client blob; composer fields keep existing validation; entity ids come from org-scopeditem.entity.
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.
- The
⋯overflow contains ONLY defer+delegate: 5 snooze presets, a divider, then Reassign (manager-only). "Log activity" no longer appears on any breakpoint. - Outcome capture is reachable ONLY through the Complete circle's flow (Item 2) — no peer "Log activity" affordance on the row or in the menu. The DO-vs-HAPPENED split is stated in one line of copy.
- When
canReassignis false the menu renders snooze presets + Pick-a-date only, with no trailing divider/empty Reassign slot. (boundary axis) - Disclosure depth stays ≤2 (row → circle → complete flow; row →
⋯→ flat defer menu); control budget unchanged/reduced (rep 1 control, manager 2; "Later" a reserved hover slot, no reflow). - Keyboard grammar consistent:
eis retired from the handler AND the cheat-sheet;c/xcomplete,sopens the defer menu,hsnoozes to tomorrow,o/Enter open.SHORTCUTS_FULL+ the announce() strings name only surviving verbs. (adjudicator must-fix #6 — single owner) - Reassign opens the task detail panel (with its real assignee control), not the no-op composer (Decision 3). No dead control.
- The dashboard widget renders the same grammar (Snooze + Complete, no Log-activity item) — verified.
view_onlyunaffected — drill-through only; keyboard suppresses mutating verbs after thereadOnlyearly-return.onQuickLog/onKbQuickLogare removed cleanly across all 5 files they thread through (atomic, tsc-clean); no dead handler.
Files.
app/work/WorkSnoozeMenu.tsx*(owned)* — remove "Log activity" +onQuickLog; menu = defer + delegate; update header doc.app/work/WorkRow.tsx*(shared — Item 3 owns theonQuickLogprop drop only)*.app/work/WorkKeyboardShortcuts.tsx*(owned)* — retiree/onQuickLog; remove the cheat-sheet row.app/work/WorkClient.tsx*(shared, coordinated with Item 2)* — droponQuickLogpass-through; Reassign repoint.app/work/WorkSection.tsx*(owned)* — drop theonQuickLogpass-through.app/components/home/HomeDashboard.tsx— inherits the de-conflated grammar automatically (the widget renders the sharedWorkRow/WorkSnoozeMenuper Decision 5); no separate Item-3 edit.app/api/tasks/[id]/route.ts*(VERIFY)* — supportsPATCH {assigned_to}withtasks.assign; Reassign is wired to open the task detail panel (Decision 3 — the panel owns the real assignee mutation), so no new mutation path is added here.
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).
- *boundary/empty/over-limit* — ACCEPT.
canReassign=false→ snooze+Pick-a-date only, no empty divider/Reassign slot. - *failure-mode* — N/A. No async mutation introduced; completion/snooze handlers are owned by other items; this item removes a menu item and re-groups the dropdown.
- *race/concurrency* — N/A. Same reason — no new mutation or shared async state.
- *permission/role* — ACCEPT. Reassign manager-only;
view_onlysuppressed; the API gate is unchanged behind the (repointed) affordance. - *related-entity/referential* — N/A. Consumes
lib/work/actions.tsread-only; introduces no entity relationship or FK. - *input-validity* — N/A. No new user input field added (menu restructure only).
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.
- The Complete circle reads unmistakably as a complete control at rest (legible glyph/tint, not hover-only) at every breakpoint, touch ≥44px, motion-safe.
- While a row is busy, its body
<Link>is inert (pointer-events-none, aria-disabled, removed from tab order) so a click can't race the optimistic removal. (sole owner of this edit) - The stale "absolutely-positioned action cluster" comments in
WorkRoware deleted (no overlay/pointer-events bug exists in the shipped DOM). (sole owner / first toucher) - The error state is no longer a dead-end "Please refresh" — it has a real Retry that SWR-revalidates (
mutate(workKey)). Loading = skeleton (not spinner), empty = the actionable "all caught up" state — both preserved. - Keyboard discoverability copy is truthful: "c/x complete, o/Enter open, s snooze-menu, h snooze-tomorrow; e is retired" — matches Item 3's grammar (VERIFY, not edit).
- The live-region announces the real result of the new completion flow (e.g., "Marked complete" + outcome-offered hint), never a false claim.
- Dashboard "Your work" widget = single source of truth:
renderYourWorkrenders<WorkRow variant="compact">driven byuseWorkActions— the bespoke row markup + duplicate handlers are DELETED. Every legibility/state/Undo/busy-inert behavior is inherited (not re-implemented); the widget owns only its limited data fetch.WorkRow'scompactvariant is denser but the same component. - A first-run legend cue (if added) stores its dismiss boolean in
sessionStorage(UI flag, not PII) mirroring the existingWorkSectioncollapse pattern — SSR-deterministic, no React #418. view_onlyunaffected (drill-through only).
Files.
app/work/WorkRow.tsx*(shared — Item 4 owns circle glyph + busy-inert Link + stale-comment deletion + thevariant: 'full' | 'compact'prop)*.app/work/WorkClient.tsx*(shared)* — error-state Retry; VERIFY announce() copy.app/components/home/HomeDashboard.tsx*(Item 4 — single-source refactor)* — DELETE the bespokerenderYourWorkrow markup + duplicaterunWorkRemoval/handleWork*; render<WorkRow variant="compact">wired touseWorkActions(homeTasksKey).app/work/useWorkActions.ts*(reserved-shared — consumed; extracted by Item 2)* — the widget drives its rows through this shared hook.- (No keyboard-file edit — VERIFY Item 3's.)
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).
- *boundary/empty/over-limit* — ACCEPT. Empty queue → actionable "all caught up"; loading → skeleton; long subject → truncation keeps the circle + trailing block legible.
- *failure-mode* — ACCEPT. Queue load error → Retry (SWR revalidate), not a dead "refresh" string.
- *race/concurrency* — ACCEPT. Busy-inert Link closes the click-races-removal window (this item's edit).
- *permission/role* — ACCEPT.
view_onlyrenders drill-through only; no new affordance reaches it. - *related-entity/referential* — DEFER_TO_HUNT. Named 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. (→ Known edge surfaces note.)
- *input-validity* — N/A. No new input field (legibility/state work only).
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:
- (Item 4) A row whose linked entity was deleted/merged *after* the queue was fetched — what the drill affordance, pressed-tint, busy-inert state, and the entity-host
?task=open should read when the target no longer resolves (the destination page 404 is the floor; the row's own cue is the open question). - (Item 1, advisory) The fetch-by-id fallback panel opening *over* a board that doesn't contain the row (panel-over-stale-board) — confirm closing returns to the board cleanly.
- (Item 2, advisory) Concurrent completion + outcome-log across two tabs/devices; the idempotency of the done-transition under repeated PATCH.
Decisions (RESOLVED by owner — 2026-06-16)
- 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.
- "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.)*
- 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.
- 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.
- 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
renderYourWorkrow 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.)