Skip to content

Show live agent work only at the end of its conversation - #3001

Open
baxen wants to merge 1 commit into
mainfrom
mbot/bottom-activity
Open

baxen wants to merge 1 commit into
mainfrom
mbot/bottom-activity

Conversation

@baxen

@baxen baxen commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Live agent work now has exactly one place: the end of the conversation it happens in, where the agent's reply will land. The message that started the work shows only what happened to it.

Before this change, an agent's work could show in three places:

  • a bubble under the request message
  • the indicator at the end of the conversation
  • the shimmer and Stop control on recorded coordination history

Mounted components decided which one showed by registering with each other (typing-presentation.tsx). But mounted isn't visible: the timeline keeps rows within 1600px mounted. So the same work moved as you scrolled, sometimes showed nowhere, briefly showed twice, and got stranded above the agent's own later replies with a second avatar and name. Recent fixes (#2454, #2613, #2851) each patched the handoff between two of those places. This PR removes the handoff instead.

The rules

  1. Live work is shown only at the conversation's end. The timeline's end shows the channel's work and a thread's end shows that thread's. The end shows dots, the current step, the transcript link and Stop, and stacks under the agent's latest reply. Nothing negotiates where it goes, and nothing depends on what is mounted or scrolled into view.

  2. A message shows only what happened to its request: Couldn't finish (with Retry or dismiss), a result Buzz couldn't confirm, or Stopped. Once the agent has replied, that note follows its latest reply (unchanged).

  3. Coordination history is history. It never animates and has no Stop. It keeps its transcript links.

  4. One rule decides which conversation work belongs to: a top-level request is its own conversation until it has replies, and then it is a thread. These all apply the same rule:

    • the channel's end (currentActivity, channel-only view)
    • the thread reply summary (threadWorkingAgents)
    • the end indicator's Stop controls
    • the outcome caption (ActivityAccessory)

    Before, a top-level request's turn, which harnesses root at the request itself, counted as "thread" work for some of these and as "channel" work for others.

What changed

Production diff is roughly +340/−1590.

Deleted:

  • typing-presentation.tsx: the presence and replacement registry, plus its providers in Conversation, MessageRow and MessageComposer
  • use-activity-indicator.ts
  • the live bubble in ActivityAccessory, with its leave animation, typing replacement and Stop
  • the composer accessory typing-presentation
  • the bundled current-activity.ts and activity-presentation.ts, which only the bubble used

activity-presentation.ts is now layout only. It no longer reads the activity snapshot: retired, working, liveParticipants, ownsLive and active are gone. History no longer re-renders on activity updates.

ActivityGroupActions keeps only the transcript links. Its Stop control is removed.

TypingIndicator:

  • No presented/replaced filtering.
  • The human typing line excludes anyone with agent activity recorded in the channel. This is derived from data rather than from registrations.
  • At the channel's end, Stop controls cover only the channel's own work and are labeled as the channel's, not "Thread · …".

currentActivity / conversationWorkStops: when a turn's records name no thread root, they fall back to the root of the turn's triggering messages (requestedRoot). Older harnesses omit threadRootEventId, and the bubble used to cover that case by message ID.

Docs: docs/agents.md, docs/buzz-help.md, and the plugin's README and description.

Tests

  • Unit: ActivityAccessory.test.tsx is reorganized around the rule.
    • New tests prove that live work shows only at the end, never on its request, for local and hosted work in both thread and channel views.
    • Another proves that work moves from the channel's end into the thread once the thread exists.
    • Another proves that history has no sheen and no Stop.
    • These new tests fail on main (c036cb0) and pass here.
    • The Stop tests (pending, retry, the 30s timeout, nothing-to-stop, not interruptible) are ported from the bubble to the end indicator, using the same fixture and the same assertions.
    • Tests that only covered the bubble or the registry are removed.
  • Full vitest run: 1028 files pass. lint and design:check are clean.
  • Browser (Playwright):
    • These specs were rewritten for the new contract, in Chromium and WebKit locally:
      • local-agent-activity.spec.mjs: 7 journeys
      • agent-activity-overhaul.spec.mjs: the hosted-thread, avatar-stack and placement journeys
      • thread-resize.spec.mjs: bottom-follow and reading-position checks as work arrives at the end
      • nested-replies.spec.mjs: branch hooks
    • In Chromium, 15 other activity-adjacent specs pass unchanged (113 tests).
    • Removed browser cases, both bubble-only: "opens an active message's empty thread without losing its activity preview" and "finishing a channel response hands focus back only when it owns focus". The end indicator's focus handoff is covered by the new "finishing work at a thread's end retires its controls without stealing focus" case, for both the indicator and the composer.

Not in this PR (follow-ups)

  • A quiet "being handled" receipt on the request message that links to the live work.
  • Showing who's working on "Jump to latest" when you're scrolled up.
  • Remote agents' step text: the end's label (Running a command) is coarser than the bubble's (Run command · cd …). Local agents are unchanged.
  • ComposerAccessoryProps.representedAgents is unused now, but contracts.ts is FOUNDATION, so it is left in place.
  • Work in a thread whose root is outside the loaded channel window counts as the channel's. This is the same limit local work already had.

Checklist

  • Agent review
  • Agent exercised the changed behavior (unit and browser journeys above; not in the native app)
  • Human tested in the app

Live agent work had several places it could show: a bubble under the
request message, the end of the conversation, and the shimmer and Stop
control on recorded coordination history. Mounted components negotiated
which one showed through a presence registry. Mounted is not visible
(the timeline keeps rows within 1600px mounted), so the same work moved
as you scrolled, sometimes showed nowhere, briefly showed twice, and got
stranded above the agent's own later replies.

Now live work has one place: the end of the conversation it happens in,
where its reply will land. The request message shows only what happened
to it (failed, unconfirmed, stopped). Coordination history is history:
it never animates and has no Stop. One rule decides which conversation
work belongs to: a top-level request is its own conversation until it
has replies, then it is a thread. The channel's end, the thread reply
summary, the end indicator's Stop controls and the outcome caption all
apply it.

The presence registry (typing-presentation), useActivityIndicator, the
live bubble and the history's live status projection are deleted. The
history projection no longer reads live activity at all.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen
baxen marked this pull request as ready for review November 7, 2026 21:05
@baxen
baxen requested a review from a team as a code owner November 7, 2026 21:05
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-07T21:09:41.510278Z 3585388 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3585388345

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +192 to +194
inThread(turn.threadRootId) ||
turns.some(
(raw) => raw.turnId === turn.turnId && inThread(raw.threadRootId),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use request roots when placing outcomes

When older harness telemetry omits threadRootEventId and lacks captured prompt context, entry.turns can still identify the conversation through its requests, but this predicate examines only turn.threadRootId and the transcript-derived root. After a top-level request gains a reply, its failed or unconfirmed outcome therefore remains visible on the channel copy instead of moving exclusively into the thread. Apply the same requestedRoot(turn) fallback already used by currentActivity and conversationWorkStops before calling inThread.

Useful? React with 👍 / 👎.

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 One additional activity-placement regression.

for (const evidence of working.filter((turn) => turn.agent === agent)) {
const turn = transcript.find((turn) => turn.turnId === evidence.turnId);
const root = turn?.threadRootId ?? requestedRoot(evidence);
const unthreaded =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P2] Preserve hosted thread ownership when its root is outside the loaded window

When a hosted turn belongs to an older thread whose root is absent from channelThreadRoots, this classification treats it as channel work. A focused comparison using a working turn with threadRootEventId = oldRoot, a request {messageId: reply, threadRootId: oldRoot}, and a roots set containing only another root returned {label: "Working", roots: [null]} at this head; the base excluded it from the channel-only indicator. The corresponding TypingIndicator filter labels its Stop control as channel-scoped and sends the conversation link to the channel rather than the thread.

Absence from the loaded window is not evidence of a top-level request without replies. Require positive evidence for channel ownership, preserve known reply/thread ownership, and apply the same predicate to the Stop filter. Add regression coverage for roots outside the loaded window.

@tellaho

tellaho commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Drip design review at 35853883457e.

The intent is to give current agent work one predictable home, with its transcript and Stop at the conversation’s end. This fits the activity bug-fix route.

Must fix — identify which request Stop affects. With two live top-level requests to the same hosted agent and neither having replies, the Working now popup produces two rows with the same agent, aggregate status and “Stop” label, but different stop targets. The new channel filtering removes their roots from presentation, and the row/control rendering cannot distinguish them. Keep a short request preview beside each distinct control and in its accessible name, independently of channel/thread classification. Cover two requests for one agent with identical current steps.

Design preference (blocks merge) — work visibility while reading history. A running request now has no receipt, and its live row/Stop scroll out of view. The existing Jump to latest only reports new messages. @mahanti: choose whether that tail-only experience is acceptable, or whether the existing Jump control should indicate running work and lead to its controls. I prefer the latter; it preserves one live row without making readers guess whether a request is being handled.

Limits: source review only; no supplied screenshots, app or tests run. Rendered themes, widths, enlarged text, keyboard/focus, reduced motion and real local/remote lifecycle behavior remain unverified.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord’s automated source review via Wes’s account, integrating Rocket, Mantis and Groot.

One P2 correctness finding is inline: after raw activity eviction, a still-working hosted turn can lose its only conversation Stop control. The simplification otherwise keeps the existing conversation, history and outcome owners.

Optional follow-ups:

  • Cover retirement while keyboard focus is inside the end indicator’s Stop popup. The new browser case covers the trigger and composer; useActivityFocusHandoff(trigger) does not include the portalled popup. Browser behavior in that case remains unverified.
  • Align the outcome-scope documentation with legacy turns that lack a recorded root: MessageOutcome.visible() does not use requestedRoot, although the new docs say outcomes follow that fallback rule. This behavior predates this PR.

Reviewed head 35853883457e0004ac95c196384608b077e240ff, target b6289eb5990e7cfe3f0d4f76b23ab5a68180a288, merge-base c036cb080dd3011de251397fa61eb83e8a7881f9. Source-only: no tests, builds or app workflows were run; the author’s reported validation was not independently verified. Public description inspected; it has no attached images. This is a COMMENT review, not approval.

typeof turn.threadRootId === "string" &&
(!threadRootId || turn.threadRootId === threadRootId) &&
snapshot.turns.some(
turns.flatMap((turn) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P2] Preserve Stop for retained hosted turns after raw-buffer eviction

This iterates only turns reconstructed from snapshot.records. That buffer is globally capped at 200 records / 2 MiB (activity.ts:13–14,427–433), while snapshot.turns deliberately retains fresh lifecycle and request identity separately. If other activity evicts all raw frames for a still-working hosted turn, currentActivity can still show it via requestedRoot(evidence), but this loop never visits it and returns no Stop control. The request/history controls removed here previously used requestWork → requestActivity, which merges retained turns and survived the same eviction, so moving all controls to the end makes this a user-visible loss.

Build the hosted controls from working retained turns, augmenting them with any raw transcript, and resolve the root from the retained threadRootId or requestedRoot when raw scope is absent. Preserve terminal/capability checks. Add coverage for a fresh working turn whose raw frames have been fully evicted but whose retained scope remains known.

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.

4 participants