Approvals screen: show what the thread was doing when the batch was born
Status summary#
Implemented, then reworked twice on Misha's review: the context line shows
the thread's agent-maintained STATUS (agent/summary-updated, folded by
threadContextForScriptRun in apps/mobile/src/lib/chat.ts) in full —
no fallback: statusless threads get no line (round 3 dropped the
last-message fallback entirely). Tappable line on agent-born batch cards,
queue and history alike; the spec asserts the full status text and the
deep-link. Nothing outstanding beyond PR review. Split out of #2339's task
(tasks/complete/2026-07-30-in-thread-approvals.md › "thread-status line"),
which made the approvals screen mostly a cross-thread queue + history view.
Ask (Misha, via #2339 follow-ups)#
The approvals view should show the status of the thread at the time the request was created — extra context now that in-thread dialogs handle the live case and this screen is mostly queue + history. "What was this run even doing?" should be answerable without opening the thread.
Design#
- A held batch's
streamContext(kindscript-execution) already carriesstreamPathandscriptRunRequestedEventOffset. For batches whosestreamPathis an agent thread (/agents/…), the batch card gains one context line derived from the agent stream AT THAT MOMENT: the last visible user/assistant message at or beforescriptRunRequestedEventOffset, truncated to one line, plus the thread name. - Derivation is a pure helper in
apps/mobile/src/lib(unit-testable): feed it the agent stream's visible-message events (thereduceChatEventsvocabulary fromlib/chat.ts) and the offset, get back{ role, text } | null. The screen fetches events with react-query (getEventson the agent stream filtered to the two visible-message types, offset-bounded if the API supports it, otherwise filter client-side) — no useEffect/useState. - Tapping the context line (or the card) deep-links to the thread — the
agent-chatroute from #2339's notification destinations already lands there. - Non-agent batches (scope holds, non-agent scripts) render unchanged.
- History entries (settled batches) get the same line — it is derived from immutable history, so it works identically for queue and history rows.
Checklist#
- Pure helper: last visible message at-or-before an offset (+ unit tests
covering: message before, message after only, empty thread, exact-offset
message) —
lastVisibleMessageAtOrBeforeinlib/chat.ts(reusesreduceChatEvents' vocabulary); six tests inlib/chat.test.ts, incl. one-line collapse + blank-message skip - Approvals screen: context line on agent-thread batch cards (queue +
history), react-query fetch, thread deep-link on tap —
ThreadContextLineinapprovals.tsx, rendered byBatchCardforscript-executionbatches with an/agents/…streamPath;getEventsbounded withbeforeOffset, paged,staleTime: Infinity(immutable history) - Graceful when the agent stream fetch fails or is slow: card renders without the line, no spinner-blocking — line renders immediately with just the tappable thread name; the message text is appended only when the fetch resolves; no pending/error branches block the card
- Playwright coverage + video (Misha's review ask on #2372) — the mobile approvals spec now visits the approvals screen after both lanes settle, asserts the context line's text on both history cards, and taps it back into the thread; 3 consecutive passes; VIDEO_MODE recording captured for the PR body
- Rework (Misha, round 2): show the thread's STATUS, not the truncated
last message —
threadContextForScriptRunfoldsagent/summary-updated(independent title/activity fields, explicit-null clears) through the run's ownscript-run-settledevent; the card shows the status IN FULL (wraps, never clipped); the spec's scripts set status like a real agent turn and the screen act asserts both cards' full text by equality; video re-recorded - Simplify (Misha, round 3): no status, no line — last-message fallback
and name-only form deleted (
lastVisibleMessageAtOrBeforegone); the fetch narrows to summary-updated + script-run-settled, which getEvents filters in SQL before the page limit, so the read is O(status events); unit test pins the setStatus-then-held-fetch shape (unsettled run → status shows) -
pnpm typecheck && pnpm lint && pnpm knip && pnpm test; PR hygiene — all four green from the worktree root
Out of scope#
- Web dashboard approvals surface
- Live status ("thread is currently working") — this line is a snapshot at request time, deliberately
- Any protocol/event changes — this is a pure read-side feature
Implementation log#
- Helper went into
lib/chat.tsrather than a new module: it is pure chat-vocabulary logic and reusesreduceChatEvents, so the visible-message event types stay defined in exactly one file. Tests sit with the existingchat.test.tssuite and reuse its event fixtures. getEventssupportsbeforeOffset(exclusive upper bound), so the fetch is server-bounded toscriptRunRequestedEventOffset + 1and filtered to the two visible-message types; the queryFn still pages (afterOffset cursor, loop until empty page, same shape asreconcileBacklog) because a busy thread can exceed one page and only the last message matters.- The line sits on the card surface (after the headline), not behind the
details expander — so settled history rows, which start collapsed, show it
too. Decision: the thread name renders synchronously from
streamPath(/agents/prefix stripped, same convention as chat.tsx's title) and is tappable straight away; theyou:/agent:message text joins it when the one-shot fetch lands. Slow/failed fetch = name-only line, never a spinner. - Helper collapses whitespace to one line and skips blank messages — a visible message with no visible text would render an empty context line.
No playwright spec (per spec): the derivation is unit-tested, the wiring is a plain react-query read.Misha asked for visible proof on the PR, sospecs/mobile/approvals.spec.tsgrew a final act: back out of the thread, drawer → Approvals, assert both settled cards' context lines (<thread name> · you: /script const burst…), tap one, land back in the thread. Two determinism fixes surfaced while writing it: (1) lane 1's narration is awaited before lane 2's command so the reject batch's context snapshot is always the command message; (2) that wait polls the protocol, not the DOM — the live activity card streams the script's code expanded, which contains the same "approve-me outcomes:" literal as the narration (getByText hit a strict-mode violation on exactly that).- Round-2 rework (Misha: the truncated "you: /script con…" line is useless;
show the STATUS, set via
itx.agent.appendlike an agent would, in full):- Bound decision: a status the script sets lands AFTER
scriptRunRequestedEventOffset(same stream, later offset), so an offset-at-or-before fold would exclude exactly the status the run set. The fold's upper bound is instead the run's ownscript-run-settledevent (matched byexecutionId; no bound while unsettled) — single stream, no cross-stream clock comparison, and "this run's status" includes what the script wrote before (or after) its held fetch, while a later turn's status is excluded. - Fallback decision: threads with no summary events fall back to the last visible message at-or-before the run request, one-lined (a user's ask is still better context than nothing); name-only when the thread is empty. The full-text no-clip guarantee applies to the status form only.
- The spec's inter-lane guard became "run 1 settled" (was "narration landed"): settlement closes batch 1's fold window, so lane 2's status appends can never leak into the approve card's context.
- Bound decision: a status the script sets lands AFTER
- Round-3 simplify (Misha: "fallback is null — just don't show anything if
there's no status", plus an efficiency question):
- Fallback deleted.
threadContextForScriptRunreturns{ title, activity } | null; the card renders no line for statusless threads, and while the fetch is pending or failed — one render form. - Efficiency answer (verified in
stream-storage.tsgetRangeSized): theeventTypesfilter is applied in SQL inside the page subquery, BEFORElimit, and event bodies are only joined for selected offsets — so a filtered read never burns pages on skipped events. Narrowed to the two status-fold types, the read is O(status events) per thread: one ~empty page for any realistic thread. The only linear-in-thread cost is SQLite's internal metadata index walk inside the DO — no RPC, no bodies. Not RBAR. - Misha's sanity check confirmed and pinned by unit test:
setStatus(...); fetch(...)with the fetch parked at the door → no settlement yet → the fold has no upper bound and the run's own status shows on the card while the batch is held. - Video NOT re-recorded: the happy path it shows (full status on both cards, deep-link tap) is unchanged by the fallback removal.
- Fallback deleted.
- Review-bot fixes after ready-for-review:
- AI-linter explain-type-cast threads: the two payload casts in
threadContextForScriptRungot the approvals.ts treatment (cast to the field subset touched + per-field runtime guards; no schemas — mobile keeps zod out of this boundary). Settle lookup is equality against a known executionId (malformed = never matches); title/activity accept string-or-null only, so malformed fields preserve rather than clear. - Bugbot "premature null cached forever": agents Promise.all the status
append with the work, so the card's fetch can run before the status
lands.
threadContextForScriptRunnow returns{ settled, status }— settled (the run's own settle event was in view) closes the fold window and makes the result immutable. The query caches forever only then; unsettled results get staleTime/refetchInterval of 5s until settlement. Unit test pins provisional null (unsettled) vs immutable null (settled). The spec cannot pin this race deterministically (its scripts await the status append before the burst, and the screen is visited only after settlement), so the conditional staleness is the fix, unforced.
- AI-linter explain-type-cast threads: the two payload casts in