Effort
~2 weeks
Surfaces touched
3 packages
New tables
2
Feature flag
task_comments_v1
01

## Milestones

Ship in four slices, each independently reviewable and each behind the flag. Nothing is user-visible until slice 4.

Week 1 · Mon–Tue

### Schema & API contract

New `comments` and `comment_reads` tables, migrations, and the tRPC router stubs. No UI. Contract reviewed before anything else lands.

packages/dbpackages/apimigration 0042
Week 1 · Wed–Fri

### Thread component & composer

Static `<CommentThread>` rendered from fixtures. Optimistic insert on submit, rollback on failure, one level of nesting only.

apps/webstorybook
Week 2 · Mon–Wed

### Realtime fan-out & unread state

Subscribe the open card to its comment channel. Track per-user read cursors so the sidebar can show an unread count without a second query.

packages/realtimeapps/web
Week 2 · Thu–Fri

### Notifications, flag ramp, docs

Mention detection → notification row, email digest fallback, ramp `task_comments_v1` to internal, then 10% → 100% over three days.

packages/notifygrowthbook
02

## Data flow

Optimistic write path on the left, fan-out on the right. The read cursor update is fire-and-forget — we never block the thread render on it.

Solid = request/response path. Dashed clay = realtime fan-out. The composer never waits on the dashed path.

03

## Mockups

Not pixel-final — just enough that the reviewer and I agree on nesting depth, composer placement, and what the sidebar digest looks like.

A · Thread inside an open task card
Ship onboarding empty-state rewrite
BIR-1142 · Assigned to Priya · Due Fri
JM
Jonah M. 2h ago
Should the illustration swap when the workspace already has one project? Feels odd to show the "start here" art twice.
Reply
PS
Priya S. 40m ago
Good catch — I'll gate it on `projects.count > 0` and fall back to the minimal variant.
Add a comment…
Post
B · Sidebar unread digest
JM
Jonah commented on BIR-1142 — "Should the illustration swap when…"
AK
Aiko mentioned you on BIR-1098 — "@priya can you confirm the copy here?"
RW
Rowan replied on BIR-0971 — "Merged, thanks for the quick turnaround."
04

## Key code

The two pieces most likely to be done wrong: the migration (soft deletes, read cursors) and the optimistic mutation (temp-id reconciliation).

packages/db/migrations/0042_comments.sql

    create table comments (
      id          uuid primary key default gen_random_uuid(),
      task_id     uuid not null references tasks(id),
      parent_id   uuid references comments(id),   -- one level only,
                                                    -- enforced in API
      author_id   uuid not null references users(id),
      body        text not null,
      created_at  timestamptz not null default now(),
      deleted_at  timestamptz                       -- soft delete
    );

    create table comment_reads (
      task_id    uuid not null references tasks(id),
      user_id    uuid not null references users(id),
      read_up_to timestamptz not null,
      primary key (task_id, user_id)
    );

    create index comments_task_created
      on comments (task_id, created_at);

apps/web/hooks/useAddComment.ts

    export function useAddComment(taskId: string) {
      const qc = useQueryClient();
      return trpc.comments.create.useMutation({
        onMutate: async (input) => {
          const temp = { ...input, id: `temp-${nanoid()}`,
                         createdAt: new Date(), pending: true };
          qc.setQueryData(key(taskId), (prev) =>
            [...(prev ?? []), temp]);
          return { tempId: temp.id };
        },
        onSuccess: (row, _v, ctx) => {
          // reconcile temp id → real id so the
          // realtime append doesn't duplicate it
          qc.setQueryData(key(taskId), (prev) =>
            prev.map((c) => c.id === ctx.tempId ? row : c));
        },
        onError: (_e, _v, ctx) => {
          qc.setQueryData(key(taskId), (prev) =>
            prev.filter((c) => c.id !== ctx.tempId));
        },
      });
    }

05

## Risks & mitigations

Risk
Sev
Mitigation
Realtime duplicate: socket append races with the HTTP response and the temp-id reconcile.
HIGH
Dedupe on server-assigned `id` in the cache updater; socket payload carries the real id, temp rows are filtered on reconcile.
Unread counts go stale when a user reads the thread on another device.
MED
Broadcast `comment_reads` upserts on the same channel; client treats its own cursor as max(local, remote).
Mention detection false-positives on pasted markdown (`@media`, `@2x`).
LOW
Resolve mentions against workspace members only, at write time, and store the resolved user ids — never re-parse on read.
06

## Open questions

Do we allow editing, or only delete-and-repost?
Editing needs an `edited_at` column and an "edited" affordance. Delete-and-repost is simpler but loses the reply anchor. Leaning toward delete-only for v1.
Decide with · design, before slice 2
Email digest cadence when a user has the app closed
Immediate-per-mention will be noisy. Proposal: batch on a 15-minute window, collapse to one email per task, and respect quiet hours from the existing settings table.
Decide with · platform, before slice 4