Skip to content

Clear "waiting on you" badges once you have read the task - #8

Open
Frailrain wants to merge 1 commit into
iishyfishyy:mainfrom
Frailrain:feat/badge-read-tracking
Open

Clear "waiting on you" badges once you have read the task#8
Frailrain wants to merge 1 commit into
iishyfishyy:mainfrom
Frailrain:feat/badge-read-tracking

Conversation

@Frailrain

@Frailrain Frailrain commented Aug 1, 2026

Copy link
Copy Markdown

"Waiting on you" badges clear on read

The story

Right now the badge is pure turn state: tasks.awaiting_input, no memory of whether the user has actually seen the task. Open a settled task, read it, close it — the badge stays lit until you reply. A fleet of finished sessions yells across every project badge and the titlebar pill in parallel, one dot per task, until each gets a message it doesn't need. It scales badly with how you actually use Operator.

This PR adds read-tracking. Opening a task with the transcript on screen for ~2 seconds stamps a last_viewed_at; the "waiting on you" surfaces clear once viewed and re-arm only when fresh agent activity postdates that stamp. The finished fleet stops nagging as you scroll through it. A parked ask still counts (see below).

Design decisions worth reviewing explicitly

awaiting_input on the wire changes meaning

This is a deliberate reshape of an API field — please don't discover it in the diff.

Before: everything (server + client) read awaiting_input straight from the DB column, which is the raw agent-state flag ("the agent's turn ended and it's your move").

After: the DB column is unchanged — internal writers (runner, ask_user tool, status PATCH, /clear, /api/instance/idle telemetry, getInstanceUsage rollup) still read and write the raw value. But every user-facing surface projects a derived value in that same field (listProjects.awaiting_count, listTasks.awaiting_input, listAllTasksLite.awaiting_input, GET /api/tasks/[id], GET /api/events payloads, countAwaiting, listNeedsYou). The derived value is the shared NEEDS_YOU predicate applied per row.

Why same field name, not a new one? Because format.ts isAwaiting(t) and every client-side row-mutation site already read t.awaiting_input and interpret it as "should this task show as waiting on you?" — which is what the derived value now answers correctly. Adding a parallel derived_awaiting field would leave the raw one on the wire as a footgun. Reviewers should confirm each writer keeps the raw semantics and each reader wants the derived one; the per-site map is in the source comments where each query lives.

A parked ask is exempt from view-clearing

The predicate is NEEDS_YOU AND (running = 1 OR last_agent_activity > last_viewed_at). The running = 1 short-circuit means an unanswered AskUserQuestion (which parks mid-turn with running=1 + awaiting_input=1) always counts, even after view. Reading a question isn't answering it — the badge stays until the ask is resolved by ask_answered (or the turn ends). Only settled turns (running=0 + awaiting_input=1) clear on read.

This also preserves the fix from 0001fe4 (July 2026) that removed the running=0 filter after a launch-feedback user hit a phantom "N need you" pill. That behavior is pinned by the extended matrix in needsYou.test.ts.

last_agent_activity is a subquery, not a column

last_agent_activity is derived per row as MAX(messages.created_at) where role IN ('assistant','tool','system'). This is the same shape listNeedsYou's waiting_since has always used, and it hits idx_messages_task.

Honesty note: I did not run measurements against any hot path. The subquery is theoretically fine (indexed by task_id, small message-per-task cardinality in practice), but if listProjects or listTasks shows up on a profile after this ships, the escape hatch is to add a tasks.last_agent_activity_at INTEGER column, stamped by the runner alongside every persisted assistant/tool/system message, and replace the subquery in NEEDS_YOU's definition in one place (the constant is deliberately factored into LAST_AGENT_ACTIVITY_SUB so this swap is one edit). Not doing it preemptively per the brief.

What "viewed" means, precisely

A task counts as viewed when its transcript stream has delivered the snapshot event, while the tab is visible, sustained for ~2 seconds. Cancelled by visibility drop or stream close. Fired by useTaskStream.ts — which is where selection, tab visibility, and deep-link navigation already converge — via POST /api/tasks/[id]/view, a simple stamp that publishes task_updated so /api/events broadcasts the fresh derived value to every tab without polling.

The 2-second gate handles the two obvious foot-shooters: a tab parked-in-the-background whose SSE snapshotted anyway, and a short-outcome task that got clicked to bounce off before actually being read. The timer rearms on subsequent assistant | tool | turn_end events so a live turn ending on your screen also clears once you've read it.

Migration

tasks.last_viewed_at INTEGER NOT NULL DEFAULT 0, added in lib/db.ts migrate() via the existing idempotent-ALTER pattern. Existing rows backfill to 0 = never viewed, which is the correct upgrade behavior: they show as awaiting on the next agent activity, which is exactly what an upgrader who was mid-workflow expects.

Test plan

  • tests/needsYou.test.ts — extended in the same commit as the predicate change:
    • Full state × viewed matrix (80 rows): running-parked + viewed still counts; settled + unviewed counts; settled + viewed clears.
    • Re-arm after view: new agent activity postdating the stamp flips derived back to true.
    • User messages don't re-arm (only assistant/tool/system roles count as agent activity).
    • POST /api/tasks/[id]/view: stamps, flips derived, publishes task_updated; 404 on unknown; parked ask stays lit after view.
    • Migration idempotence (double migrate() is a no-op) + backfill correctness.
  • Full suite: npm test → 278 passing across 37 files.
  • Manual: open a completed awaiting task, watch the badge clear after 2s; switch tabs mid-count, confirm it doesn't fire until the tab is visible again; verify a parked ask stays lit after viewing.

A fleet of settled awaiting tasks keeps yelling on every project badge and the
titlebar pill until each gets a reply — opening and reading a task doesn't
clear it. Add read-tracking: a task counts as viewed once its transcript
stream has delivered the snapshot with the tab visible for ~2 seconds; the
"waiting on you" surfaces clear once viewed and re-arm only if fresh agent
activity postdates that view. A parked ask (running=1 + awaiting_input=1)
stays lit regardless — reading a question isn't answering it.

Schema: tasks.last_viewed_at INTEGER NOT NULL DEFAULT 0 with an idempotent
ALTER; existing rows backfill to 0 so they show as awaiting on the next agent
activity, which is the right behavior on upgrade. Predicate: one shared
NEEDS_YOU now reads suggested = 0 AND status = 'in_progress' AND
awaiting_input = 1 AND (running = 1 OR MAX(messages.created_at) for
assistant/tool/system > last_viewed_at). Endpoint: POST /api/tasks/[id]/view
stamps last_viewed_at and publishes task_updated so /api/events broadcasts the
fresh derived awaiting_input + project awaiting_count to every open tab; no
polling. Client: useTaskStream owns a 2s timer armed by snapshot delivery and
by subsequent assistant/tool/turn_end events, cancelled by visibility drop or
stream close.

The needsYou.test.ts matrix is extended in the same commit as the predicate
so the parked-ask-viewed case and settled-viewed-clears case are pinned
together with the change that introduced them.

Signed-off-by: Matt Hersee <Matthersee@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant