Skip to content

feat(composer): surface live agent activity in status pills - #2646

Draft
tellaho wants to merge 28 commits into
mainfrom
tho/composer-live-activity-resurrection
Draft

feat(composer): surface live agent activity in status pills#2646
tellaho wants to merge 28 commits into
mainfrom
tho/composer-live-activity-resurrection

Conversation

@tellaho

@tellaho tellaho commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Category: improvement
User Impact: Working agents now appear as compact composer status pills that show their latest action, preview live activity on hover, and open the full session on click.

Problem: The combined “agents working” control hid which agent was doing what, shifted the composer when it mounted, and required an extra interaction to understand recent activity. Human typing and agent activity also competed for space without a clear signal hierarchy, while crowded thread composers could compress activity labels until they were unreadable.

Solution: Replace the combined control with a fixed-height pill strip for observer-backed agents, while grouping typing-only agents with human typers. Pills preserve turn-start ordering, retain the latest meaningful channel-scoped headline for the life of the turn, freeze safely while hovered, preview the shared compact activity feed, and open the full agent session on click. Thread-only bot typers stay in the combined typing group until channel-scoped observer activity provides a real session to preview, then promote to a pill while retaining the truthful “is typing…” label.

File changes
  • Agent working state: Adds channel-scoped observer/typing signals and pure helpers for capability partitioning, persistent headlines, turn-start ordering, and archive-aware recency.
  • Composer UI: Replaces the combined trigger with animated per-agent pills, shared hover-card control, responsive overflow affordances, and a fixed-height activity row shared by the main composer.
  • Thread composer: Applies the same capability partition without leaking thread typing into channel-wide working state; transcript-less agent typers remain non-interactive until observer activity exists.
  • Activity preview: Reuses the managed agent transcript presentation in an inert compact hover preview and shares Last-live formatting with the profile panel.
  • Typing presentation: Combines typing-only agents with human typers and supports composer-specific styling.
  • Tests: Covers label selection, filtering, ordering, hover stability, click-through, overflow geometry, focus-mode anchoring, and thread typing-group-to-pill promotion.

Reproduction steps

  1. Open a channel where one observer-backed agent is working; confirm a fixed-height pill appears with the latest meaningful action.
  2. Emit more actions; confirm the headline updates in place and remains visible without freshness decay while the turn is active.
  3. Hover the pill; confirm its order and width freeze while the compact transcript preview is open.
  4. Click the pill; confirm the full agent session opens.
  5. Start multiple agents; confirm turn-start order is stable, and narrow the composer to verify horizontal overflow fades.
  6. Start a first-ever typing-only agent; confirm it joins the combined typing group rather than exposing an empty activity preview.
  7. In a thread, start a transcript-less agent typer; confirm it remains in the typing group. Add channel-scoped observer activity and confirm it promotes to a pill labeled “is typing…”.
  8. Enable reduced motion; confirm label and pill transitions become immediate.

Screenshots / demos

Narrow composer strip

Narrow composer strip

Hover activity preview

Hover activity preview

Validation

Validated at cb8118e9cee2b48bfa9fdeb4fe76ef80bac082ae rebased onto origin/main at a7ea86cdcf2aa3cbb1486baabc4c9d6aea8c576c:

  • Biome on the modified composer/activity files and E2E spec
  • Desktop TypeScript typecheck
  • Desktop file-size ratchet (including ChannelPane.tsx at 998 lines)
  • Focused Playwright regression: thread agent typing promotes only when activity exists — 1 passed
  • git diff --check
  • git merge-tree --write-tree HEAD origin/main
  • Branch metadata audit: all 28 commits authored by Taylor Ho with exactly one contiguous co-author/sign-off pair
  • Pre-push gates passed: org guard, branch skew, desktop check, full desktop tests, Rust tests, and desktop Tauri checks
  • Full mobile suite had one unrelated timing-bound failure in relay_session_test.dart (3968ms vs expected 3990–4000ms); the exact failing test passed immediately in isolation

@tellaho
tellaho requested a review from a team as a code owner July 23, 2026 23:16
@tellaho
tellaho marked this pull request as draft July 27, 2026 02:14
@tellaho
tellaho force-pushed the tho/composer-live-activity-resurrection branch from c3079e0 to 538223d Compare July 27, 2026 07:06
@tellaho tellaho changed the title add selected-agent live activity to the composer show live agent activity in composer status pills Jul 27, 2026
@tellaho tellaho changed the title show live agent activity in composer status pills surface live agent activity in composer status pills Jul 27, 2026
@tellaho
tellaho marked this pull request as ready for review July 27, 2026 09:21
@tellaho
tellaho marked this pull request as draft July 27, 2026 16:42
@tellaho
tellaho force-pushed the tho/composer-live-activity-resurrection branch from 6b3dfff to 1d4356b Compare August 2, 2026 04:30
@tellaho tellaho changed the title surface live agent activity in composer status pills feat(composer): surface live agent activity in status pills Aug 2, 2026
@tellaho
tellaho force-pushed the tho/composer-live-activity-resurrection branch 2 times, most recently from eadac0e to dc3a698 Compare August 5, 2026 05:41
tellaho added 16 commits August 5, 2026 16:41
Rebuild of the composer live-activity experiment (old draft PR #1575) on
current main primitives. Behind the new off-by-default
composerLiveActivity preview feature, the agents-working popover shows
one selected working agent's channel-scoped feed at a time:

- ComposerLiveActivityFeed reuses ManagedAgentSessionPanel with the
  compactPreview transcript variant (same primitive as the profile
  activity embed), so live/archive merging, projection, anchored
  scrolling, and idle handling stay owned by that surface.
- Agent switching via pill tabs (role=tablist) under the preview;
  selection resolves explicit tab > open session pane > first working
  agent, and falls through when the selected agent stops working.
- Whole preview is one click target with a Last-live recency pill;
  transcript rows underneath are inert, avoiding nested interactive
  controls inside a clickable shell.
- formatLastLiveLabel extracted from UserProfilePanelTabs into
  profile/lib/lastLiveLabel so both surfaces share the recency label.

With the experiment off, the popover renders the existing agent list
unchanged.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…nd AT

Wrap the ManagedAgentSessionPanel subtree in a native inert container so
compact transcript rows (which can render keyboard-focusable message
links) are removed from pointer, tab-order, and assistive-tech
interaction — CSS pointer-events-none only neutralized the mouse. The
overlay open button and Last-live pill stay accessible.

Also extract deriveLastLiveAt and make it archive-aware: the Last-live
pill now takes the newest across the live transcript window, archived
channel events, and the active-turn anchor, so it can no longer disagree
with archived rows visible in the preview.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…gent turns

- Extract the composer status strip into ChannelComposerActivityRow.tsx
  (new component hosting the inline bot-activity trigger and the typing
  indicator, plus the useChannelWorkingAgentPubkeys subscription). Also
  brings ChannelPane.tsx back under the 1000-line file-size ceiling
  (1006 -> 969).
- Change the row from min-h-8 to a fixed h-8.5 (34px). The inline
  bot-activity trigger is h-7 (28px), which plus the row's pb-1.5 (6px)
  made the active row 34px — 2px taller than the 32px idle minimum — so
  the bottom-anchored composer visibly bumped up whenever an agent turn
  or bot typing mounted the trigger, and dropped back when it cleared.
  34px matches the previous active-state height exactly: the working
  state renders identically and the idle state now reserves the same
  space. (Typing indicators alone are 20px of content, under the old
  minimum, which is why only the agent-activity trigger caused the
  shift.)
- channels.spec.ts: add "composer does not shift when the activity row
  mounts and clears" e2e test asserting the composer's y-position stays
  stable across trigger mount and clear. Verified via negative control:
  with min-h-8 restored the test fails with exactly the 2px delta.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
- BotActivityBar.tsx: replace the combined "agents working" trigger with one
  pill per working agent (avatar + latest action summary in a rounded-full
  chip, max-w-30 with truncation so pill widths stay uniform); extract
  BotActivityAgentPill with per-pill hover popover state (useHoverPopover)
  and remove the tab strip, tab selection state, and the 5-headline
  rotation interval
- Pill label shows the agent's single latest action headline and decays to
  a generic "Working…" state once activity goes quiet (15s staleness
  window, 5s useNow tick); deliberately no rotation between recent actions
- Hovering a pill shows the live activity feed as the popover surface
  itself (w-80 p-0, edge-to-edge, no inset bordered box); clicking the
  pill opens the agent's full runtime in the auxiliary panel
  (preventDefault stops Radix's composed trigger toggle from racing the
  hover/focus open state, which made click-to-open flaky)
- composerLiveActivity.ts: add deriveActivityPillLabel pure helper
  (channel-scoped spine-first scan + staleness decay,
  ACTIVITY_PILL_STALE_MS = 15s) and remove the now-unused
  resolveSelectedActivityAgent
- composerLiveActivity.test.mjs: replace resolveSelectedActivityAgent
  tests with 7 deriveActivityPillLabel cases (fresh headline wins,
  staleness decay, custom window, channel scoping, spine-over-metadata,
  metadata fallback, empty transcript)
- ComposerLiveActivityFeed.tsx: overlay button rounding rounded-lg →
  rounded-[inherit] so the hover tint follows the popover's rounded-xl
  clip when the feed renders edge-to-edge
- ChannelComposerActivityRow.tsx / ChannelPane.tsx: drop the removed
  variant prop (the toolbar variant was unused; both call sites rendered
  inline)
- channels.spec.ts: activity-indicator test now hovers the pill for the
  legacy preview popover and clicks the pill for direct aux-panel open
- Flag-off (composerLiveActivity disabled) hover popover keeps the legacy
  "View activity" item; pills stay h-7 inside the fixed h-8.5 activity row
  so the composer-shift guard keeps passing

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
- BotActivityBar.tsx: extract AnimatedPillSlot around each agent pill —
  reorders animate with a 0.9s spring (bounce 0.15, subtler overshoot) and
  an opacity dip via [1, 0.35, 1] keyframes running the same duration with
  linear easing, so the fade lands together with the layout switch instead
  of trailing it
- Label updates render as a clipped push-up ticker (h-3.5 overflow-hidden
  container; the new text pushes the old text up and out, 0.28s tween,
  instant under reduced motion); swaps are deferred while the pill's slot
  is mid layout animation (AnimatedPillSlot passes isMoving via render
  prop) and play once the spring settles, otherwise immediately
- Ticker keys on the backing transcript item id, not the label string: a
  genuinely new action animates, while streamed chunks that grow the same
  item (message first line, thought text) update text in place — fixes
  phantom re-animations of visually identical truncated text
- composerLiveActivity.ts: deriveActivityPillLabel now returns
  ActivityPillHeadline {id, label} and skips usage_update /
  available_commands_update lifecycle meta frames, which previously
  headlined the pill as bare "Usage"/"Commands" between real actions
- animations.css: .buzz-shimmer.truncate::before ellipsizes the glow
  overlay copy the same way as the base text, so the shimmer sweeps the
  ellipsis instead of revealing the clipped rightmost characters
- Pill max width raised from max-w-30 (120px) to max-w-36 (144px)
- channels.spec.ts: activity-indicator test now asserts the flag-ON
  reality (E2E seeds every preview feature, so composerLiveActivity always
  renders): hovering the pill opens the live-activity feed popover and
  clicking opens the agent session in the aux panel — replaces the legacy
  "View activity" item assertions, which only ever passed via a popover
  toggle race in the pre-pill trigger
- composerLiveActivity.test.mjs: object-shape assertions plus new cases —
  meta frames never headline, meta-only transcripts stay generic, and the
  headline id stays stable while a message streams (19 tests passing)

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…ster label decay

Composer activity strip (BotActivityBar.tsx):
- Centralize hover popover state at the strip level (useStripHoverPopover):
  ONE active pill and ONE timer for the whole strip, replacing per-pill
  timers that raced each other (double-open on pill-to-pill travel, stuck
  cards when the cursor clipped an open card)
- Freeze pill order, membership, layout animation, and pill widths while
  the cursor is over the bar or a hover card is showing; queued reorders
  apply (animated) after the hold releases
- Pin the hovered pill's rendered width so label swaps can't shift
  neighboring pills under the cursor
- Open on keyboard focus only for :focus-visible, and preventDefault on
  the card's close auto-focus, fixing the re-open loop when Radix
  returned focus to the trigger
- Typing-fallback-only agents (working source "typing", no observer turn)
  now render a passive status pill: borderless, no max-width cap, no hover
  card, no click-through, not focusable — there is no transcript or
  session behind the typing signal to open

Pill label decay (composerLiveActivity.ts):
- Shorten ACTIVITY_PILL_STALE_MS 15s → 6s so pauses in the event stream
  cycle the pill back to the generic label promptly; tick 5s → 1s
- Generic label is now agent-named ("<name> is working…") instead of the
  bare "Working…"

Debug harness (features/agents/debug):
- Add per-agent "Typing" toggle simulating the basic "is typing"
  situation: a synthetic channel-scoped typing entry with no observer
  turn, exercising the typing-fallback path (useChannelActivityTyping →
  reportChannelBotTyping → passive pill); independent of Working/Progress
  so observer-over-typing precedence is testable; "End all turns" also
  clears typing
- useDebugHarnessTypingEntries appends the synthetic entries in
  useChannelActivityTyping (dev-only, reference-stable when idle)

Tests:
- channels.spec.ts: e2e coverage for hover-freeze — card freezes pill
  order with queued reorder applying on close, and bar-hover freeze
  without an open card
- composerLiveActivity.test.mjs: freshness fixtures updated for the 6s
  stale window

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…is working…"

- BotActivityBar.tsx: the generic decay label on a typing-fallback-only
  pill (working source "typing", no observer turn) now reads
  "<name> is typing…", matching the human typing indicator's vocabulary;
  observer-backed pills keep "<name> is working…"
- A fresh action headline still wins over both generic labels
- Both generic labels share the same ticker id, so a typing → observer
  source upgrade updates the text in place instead of playing the
  push-up swap

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…group

- ChannelComposerActivityRow.tsx: partition the channel's working set by
  signal source — observer-backed agents keep the interactive activity
  pills, while typing-fallback-only agents (no observer turn, nothing to
  hover or open) are diverted out of the pill strip and merged with the
  human typers into ONE combined TypingIndicatorRow group with an
  overlapping avatar set; channel-agent roster names are overlaid onto
  the profile lookup so roster-only agents never fall back to truncated
  pubkeys
- Fix row layout: the pill strip wrapper and typing row were both flex-1,
  splitting the row 50/50 and stranding the typing group at the halfway
  mark — the strip now sizes to content (still shrinkable) and the typing
  group takes the remainder; row gap set to gap-3, and the typing row's
  base px-4/sm:px-6 padding is zeroed next to pills (composer-edge
  alignment padding only applies when the group leads the row)
- TypingIndicatorRow.tsx: 4+ typers label now reads "X, Y, and N more are
  typing..." per spec (was "N others"); new optional labelClassName prop
  so the composer row can match the pills' font-semibold weight without
  changing the thread panel's indicator
- BotActivityBar.tsx: revert the passive typing pill variant and
  "is typing…" label — typing-only agents never reach the pill anymore;
  pill avatar bumped 18px → 20px with a uniform 3px surround
  (pl-0.75, h-7 minus 1px borders) and asymmetric pr-2 for label
  breathing room
- debug/README.md: document that the harness Typing toggle now renders
  through the combined typing indicator group

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…composer

The thread footer's activity/typing row was centered into an mx-auto
max-w-4xl column while the thread composer above it spans the full pane
width, so in a wide thread pane the typing indicator floated toward the
pane's center instead of hugging the composer's left gutter.

- MessageThreadPanel.tsx: drop the mx-auto/max-w-4xl wrapper so the row
  spans the same width as the composer above it
- Remove flex-1 from the toolbarExtraActions (agent pill strip) wrapper —
  it previously ate half the row and pushed the typing group to the
  midpoint whenever it mounted; the strip now sizes to its content,
  mirroring ChannelComposerActivityRow
- Make the typing indicator's leading padding conditional like the
  channel row: composer-edge padding only when the typing group leads
  the row, pl-0 when it follows pills so the flex gap is the whole
  spacing

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…ip when narrow

Remove the composerLiveActivity preview flag so the live-activity hover
preview ships for everyone, and land the pill strip overflow behavior
for narrow containers.

Flag removal (ships by default):
- preview-features.json: drop the composerLiveActivity entry — absence
  from the manifest means stable/always-on; E2E preview seeding and the
  Settings > Experiments card both derive from the manifest, so they
  pick this up automatically
- BotActivityBar.tsx: remove the useFeatureEnabled gate and the legacy
  flag-off "View activity" popover item; the pill hover popover now
  always renders ComposerLiveActivityFeed
- Remove the now-dead openAgentSessionPubkey plumbing through
  BotActivityAgentPill / BotActivityComposerAction /
  ChannelComposerActivityRow / ChannelPane — it only backed the legacy
  item's active-session highlight
- Update stale flag references in ComposerLiveActivityFeed's doc
  comment, the agents debug harness README, and a channels.spec.ts
  comment

Pill strip overflow (BotActivityBar.tsx, ChannelComposerActivityRow.tsx):
- With several pills in a narrow container (thread panel toolbars
  especially), pills keep their natural label-truncated width and the
  strip scrolls horizontally — scrollbar hidden, edge gradient fades
  signalling clipped pills — instead of compressing every pill into an
  unreadable sliver
- New useStripOverflowFades hook tracks scroll position plus
  scroller/content resizes (ResizeObserver) so fades appear and
  disappear without a re-render triggering event
- A lone pill shrinks to fit (min-w-0) instead of scrolling — an edge
  fade over a single pill reads as a cut-off bug, not an affordance
- layoutScroll on the scroller keeps motion's slot layout animations
  measured relative to the scroll offset
- e2e: add "narrow strip scrolls horizontally with edge fades instead
  of compressing pills" and "lone pill shrinks to fit a narrow
  container without scroll fades"

Verified: tsc, biome check, desktop unit tests (3651 pass), and the
pill/strip/live-activity e2e subset. Two pre-existing channels.spec.ts
failures ("composer does not shift when the activity row mounts and
clears", "hovering the bar itself freezes pill order without opening a
card") reproduce identically on the parent commit with a clean tree —
unrelated to this change.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…ation sleeps

The composer pill strip only renders observer-backed agents — typing-only
agents fold into the combined typing indicator group by design — but the
activity-indicator and no-shift e2e tests still summoned the pill via mock
typing events, so the pill never mounted and both tests failed at the
first toBeVisible.

- Rewrite 'shows and clears activity indicators' on __BUZZ_E2E_SEED_ACTIVE_TURNS__:
  assert the typing-only fold-in first, then the observer-turn promotion
  (typing indicator clears as the pill mounts), the channel-scoped aux
  panel with the live transcript, turn_completed clearing the pill, and a
  fresh turn re-mounting it.
- Rewrite 'composer does not shift' on observer-turn seeds and extend it:
  the fixed-row contract now covers pill mount, pill clear, AND the
  combined typing group mounting — all within ±0.5px of the idle composer
  top, which would catch the reported 2px drift.
- Make the bar-hover freeze test deterministic: the strip sizes to its
  content, so 'the bar's empty right side' could land on the trailing pill
  and open a hover card. Park the cursor in the inter-pill gap instead and
  wait for the hold's width-pin style before seeding the reorder.
- Replace both 1200ms animation sleeps (0.9s spring + 180ms grace left
  only ~120ms margin on CI) with state-based assertions: the pill label
  committing the seeded message text proves the store update reached the
  strip before the frozen-order assertions run.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
- Bleed the pill strip's scroll viewport across the mounting row's px-5
  gutter (-mx-5 on the strip root, px-5 restored as scroller padding) so
  overflowing pills clip at the container's visual edge instead of the
  row's padded content box — previously a pill faded out mid-row, 20px
  shy of the edge, with a dead gutter after the gradient
- Keep at-rest alignment unchanged: the scroller's internal px-5 keeps
  the first pill on the row gutter in both mounting surfaces
  (ChannelComposerActivityRow and the thread panel toolbar row)
- Widen the edge fades from w-8 to w-12 so the gradient keeps a soft
  fade zone over the pills after spanning the 20px gutter
- Grow the pill label ticker viewport from h-3.5 to h-4 with a matching
  leading-4 line box so Inter's descenders ("g", "y") are no longer
  sheared off the label
- Extend the narrow-strip e2e test to assert both fades sit within 1px
  of the activity row's border-box edges, guarding the full-bleed
  geometry against regression

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…bels

Calm the working-agent pill strip while multiple agents stream: position
now encodes identity (stable for a whole turn), liveness stays on the
per-pill label tickers, and positional motion is reserved for membership
changes (an agent starting or finishing) — the transitions a builder
actually needs to notice. Grounded in the work-visibility and
completion-awareness JTBDs: transcript-recency ordering shuffled pills on
every agent event (cry-wolf motion) and buried quiet/stuck agents at the
scrolled-off end of the strip.

- composerLiveActivity.ts: replace deriveAgentActivityOrder (transcript
  recency, newest first) with deriveAgentWorkingOrder — sorts by
  channel-scoped turn anchorAt ascending (earliest worker left-most, new
  agents append right), quantized to whole seconds so retroactive
  clock-offset refinements cannot reorder the strip, stable roster-order
  tiebreak, no-anchor agents keep roster order at the end
- composerLiveActivity.ts: remove the 6s label decay
  (ACTIVITY_PILL_STALE_MS and the now/staleAfterMs params) — the last
  real action now persists through quiet stretches; the generic
  "<name> is working…" label only shows before the first action lands,
  since the last action is more informative than a placeholder while a
  turn is in progress
- BotActivityBar.tsx: order snapshot subscribes to agentWorkingSignal
  (turn anchors) instead of the observer store's transcript scan; drop
  the per-pill useNow(1000) staleness tick so pills no longer re-render
  every second; hover-freeze machinery (order/membership/width hold)
  unchanged and still guards membership changes under the cursor
- composerLiveActivity.test.mjs: six anchor-ordering tests (earliest
  first, append-on-later-start, channel scoping, unscoped
  earliest-across-channels, roster fallback, sub-second-shift stability);
  decay tests replaced with a headline-persists-regardless-of-age test
- channels.spec.ts: retarget the two hover-freeze e2e tests from recency
  flips to membership changes — a third agent (lyra) starting a turn
  mid-hover appends only after the hold releases, and a turn completing
  mid-bar-hover exits only after release
- e2eBridge.ts: __BUZZ_E2E_SEED_ACTIVE_TURNS__ accepts an explicit atMs
  so tests seed well-separated turn-start anchors deterministically
- channels.spec.ts: seedPillActivityMessage now takes the matching
  turnId — the old hardcoded id resurrected a phantom sibling turn that
  would have kept a completed agent "working" forever
- debugAgentHarness.ts / debug README: update decay-era comments to the
  hold-last-headline behavior

Verified: 3652 unit tests pass; 12 pill/activity/typing e2e tests pass
against a fresh build:e2e.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…pill styling

Typing group becomes a slot sibling of the working-agent pills:

- BotActivityBar.tsx: BotActivityComposerAction gains a `typingIndicator`
  prop rendered as the strip's trailing AnimatedPillSlot, so the typing
  group shares the scroller, edge fades, and layout/enter/exit animations
  with the pills; lone-item shrink logic now counts the typing slot
- ChannelComposerActivityRow.tsx: the row renders ONE strip hosting both
  groups instead of a pill strip beside a standalone TypingIndicatorRow
- ChannelPane.tsx / MessageThreadPanel.tsx: thread panel mirrors the same
  structure — ChannelPane builds the strip (thread typers included) and the
  panel's activity row just mounts it; `threadTypingPubkeys` prop removed
  from MessageThreadPanel; row height fixed at h-8.5 to match the channel
  row so the bottom-anchored composer never bumps
- TypingIndicatorRow.tsx: drop the now-unused "activity" variant — the
  strip's slot owns sizing/spacing, so only the default variant remains
- composer.css: corner-mask offset 2.5rem -> 2.625rem to match the fixed
  h-8.5 activity row below the composer in both surfaces
- channels.spec.ts: new e2e covering the typing group rendering inside the
  strip, trailing the pills, and scrolling under the edge fades

Align the "is working" pill with the "is typing" indicator:

- Remove the pill's border, background, shadow, and horizontal padding —
  hover/open states signal through text color only
- Match label typography (text-xs font-medium, was font-semibold) and the
  typing label's translate-y-px optical nudge; avatar-to-label gap-2
- Drop the labelClassName="font-semibold" overrides that compensated for
  the pill's old heavier weight (channel row + thread strip)

Also document in AGENTS.md that e2e reruns need `pnpm run build:e2e` — a
plain build omits the mock-bridge module and every spec fails at boot.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
- Rewrite the tool branch of getActivityHeadline (agentSessionTranscriptPresentation.ts)
  to use the descriptor's terse action tier — verb + compact object, e.g.
  "Read foo.ts" — instead of "label · full preview" ("Read file ·
  src/agents/ui/foo.ts"), so the composer pill's 200px cap shows the
  informative part of the action instead of ellipsizing a long preview
- Basename path-like objects only (file-read / file-edit / skill-read);
  shell commands stay whole since they legitimately contain "/", and file
  edits reuse the already-basenamed fileEditSummary.filename
- Fall back to the previous "label · preview" format when a descriptor
  carries no action (older descriptors)
- Export the existing private basename() from agentSessionFileEditDiff.ts
  rather than adding a third copy
- Document the terse format on deriveActivityPillLabel (composerLiveActivity.ts);
  no logic change — the pill picks it up via getActivityHeadline, whose only
  production consumer is the pill
- Tests: update presentation expectations ("Send Message · abc" → "Sent abc"),
  add cases for file-read/file-edit basenaming, shell commands kept whole,
  and the no-action fallback; add a pill-level terse-headline case to
  composerLiveActivity.test.mjs

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…er a resize

The overflow-fade hook resize-observes the scroller and its content
wrapper, but the wrapper was a min-w-0 flex item: under a narrow
container it collapsed to the scroller's content width, so the shrink-0
pill slots overflowed it invisibly. When a pill's deferred label ticker
swapped in a longer label AFTER the resize settled, scrollWidth grew
without resizing any observed element — no ResizeObserver callback, and
the edge fades stayed stale (missing) until a manual scroll. Caught by
the "narrow strip scrolls horizontally with edge fades" e2e spec, which
failed deterministically because label swaps are intentionally deferred
until slot layout settles.

- BotActivityBar.tsx: give the scroller's content wrapper min-w-max on a
  multi-item strip so its border box always tracks content size and every
  late growth fires the ResizeObserver; keep min-w-0 for the lone-item
  case so a single pill/typing group still shrinks to fit (shrinkToFit)
  instead of overflowing into scroll
- Side effect: pills no longer visually compress below their natural
  width inside a constrained strip (the collapsed wrapper was squeezing
  them); they keep readable widths and the strip scrolls as designed
- Document the min-w-max dependency at the ResizeObserver in
  useStripOverflowFades so the wrapper's sizing isn't "simplified" back
  into a stale-fade regression

Verified: all 13 activity/pill/typing specs in channels.spec.ts pass
(including the previously failing narrow-strip fade spec and the new
typing-inside-strip spec), plus both channel-composer-overflow specs.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
tellaho added 12 commits August 5, 2026 16:42
- Tighten avatar-to-label gap inside each working-agent pill (gap-2 -> gap-1.5)
- Widen the gap between strip items (pills + typing indicator) for clearer separation (gap-1.5 -> gap-3)

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Replace the all-or-nothing hover freeze in the composer activity bar with
a guard that only pins pill widths when a resize would actually move the
open hover card.

- BotActivityBar.tsx: drop the blanket pinWidth/freezeLayout tied to bar
  hover; compute activeCardIndex (pill anchoring the open card) and pin
  width / freeze slot layout only for pills at or left of that index.
  The card renders side="top" align="start" — anchored to its pill's
  left edge — so only those pills can displace it by resizing
- Pills right of the anchor, and every pill when no card is open (even
  with the bar hovered), now resize freely with their label swaps and
  keep slot layout animation so neighbors ease instead of snapping
- Keep the membership/order freeze on bar hover unchanged — a pill
  exiting mid-hover would slide the strip under the cursor, which is
  independent of card placement
- Expose the membership hold via a data-hold attribute on the strip
  root, since the inline width pin is no longer a proxy for it
- channels.spec.ts: bar-hover test now waits on data-hold, asserts pills
  are NOT width-pinned during bar-only hover, and parks the cursor past
  the last pill (label swaps can legitimately grow a pill into the
  inter-pill gap); add "open card pins only its anchor pill and the
  pills left of it" covering both directions; add
  waitForSettledPillBoxes helper because pills mount with short generic
  labels and grow via the slot layout spring when seeded headlines land

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…ent types

An observer-backed agent that started typing its reply mid-turn had the
typing signal swallowed entirely: computeAgentWorkingState drops a
channel's typing entry under observer precedence, so the pill kept
showing the stale last tool action and typing could only ever surface as
a separate item in the trailing typing group. Now the existing pill's
ticker swaps to "<name> is typing…" in the same slot and swaps back to
the last action headline when typing clears.

- desktop/src/features/agents/agentWorkingSignal.ts: add
  getAgentChannelTypingSince(pubkey, channelId) — a raw typing-registry
  read that deliberately bypasses the observer-precedence fold,
  returning the first-seen typing timestamp or null
- desktop/src/features/channels/ui/BotActivityBar.tsx:
  BotActivityAgentPill subscribes via useSyncExternalStore; while
  typing, the ticker keys on a dedicated TYPING_LABEL_ID and the label
  becomes "<name> is typing…" — same slot, avatar, and hover feed, no
  new strip item. Partition unchanged: typing-only agents still divert
  to the combined typing indicator group
- desktop/src/features/agents/agentWorkingSignal.test.mjs: three new
  tests pinning the getter (null cases, first-seen anchor lifecycle,
  and not-folded-under-observer-precedence)

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
The activity pill slot's single top-level 0.9s transition
(PILL_REORDER_DURATION_S) applied to every animated value, so the
enter/exit fade+scale inherited the slow spring meant only for reorder
travel — pills faded/scaled in and out over 0.9s.

- Split AnimatedPillSlot's transition per value: `layout` keeps the
  0.9s reorder spring unchanged
- `opacity` keeps the 0.9s linear tween only while the slot is moving
  (the mid-move dip keyframes must still land with the layout spring);
  otherwise it uses a fast 0.18s ease-out for enter/exit
- `scale` always uses the fast tween — it only animates on enter/exit
- Add PILL_ENTER_EXIT_DURATION_S (0.18s) alongside the reorder constant

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…he turn-end gap

The composer strip's pill-vs-typing-group partition keyed on signal
source: any typing-fallback agent went to the merged typing group. An
agent whose observer turn just completed but who was still typing
therefore flipped partitions — its pill exited and a new typing-group
item entered at the trailing slot — and re-typing after a finished turn
mounted a group item instead of relighting the pill.

Partition on capability instead: an agent renders as a pill when there
is a session worth hovering and opening — an active observer turn, or a
headline-able transcript for the channel left behind by a prior turn.
Only an agent's first-ever activity in a channel can now start in the
typing group; every later working↔typing transition is an in-place pill
relabel (the "is typing…" override from cf6014de9).

- desktop/src/features/channels/ui/composerLiveActivity.ts: add pure
  partitionComposerWorkingAgents helper (injected getWorkingSource /
  getTranscript readers, mirrors deriveAgentWorkingOrder's style);
  pill-worthiness = source !== "typing" OR deriveActivityPillLabel
  non-null, so lifecycle-noise-only transcripts ("Turn started" seeds)
  still fold into the group
- desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx:
  partition snapshot now subscribes to BOTH the working signal and the
  observer store (a transcript landing must be able to promote a typing
  agent); rename observerWorkingPubkeys → pillBotPubkeys to match the
  capability semantics
- desktop/src/features/channels/ui/composerLiveActivity.test.mjs: six
  new tests — observer always pills, transcript-less typing groups,
  turn-end gap keeps the pill, lifecycle-noise-only transcripts group,
  channel scoping, mixed-roster order preservation
- desktop/src/features/channels/ui/BotActivityBar.tsx +
  desktop/src/features/agents/debug/README.md: docs updated to the
  capability partition

E2E: shows-and-clears, composer-does-not-shift, typing-group-in-strip,
typing-avatars, and the pill hover-freeze suite all pass (8/8 smoke).

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…tition flips

The one strip transition that can't be an in-place relabel — an agent's
first promotion from the merged typing group into a status pill — played
as exit-here/enter-there: the avatar dropped out of the overlapping
group while a new pill faded in beside it. Give both avatar homes a
matching motion layoutId so the avatar MORPHS from the group into the
new pill (geometry keeps the hop short: a new pill appends rightmost,
directly adjacent to the trailing typing slot).

- desktop/src/features/channels/ui/composerStripAvatarLayout.ts: new
  leaf module — ComposerStripAvatarScopeContext (per-strip namespace),
  useComposerStripAvatarLayoutId, and the shared morph spring. layoutIds
  are app-global and the channel strip + thread panel strip can be
  mounted at once, so ids bake in a per-strip scope string. Motion's own
  LayoutGroup id namespacing is deliberately NOT used: LayoutGroup
  re-measures every layout-animating member whenever any member updates,
  which turned each pill label ticker resize into a group-wide
  onLayoutAnimationStart storm that permanently held the pills' deferred
  label swaps (caught by the hover-freeze e2e test)
- desktop/src/features/channels/ui/BotActivityBar.tsx: strip provides
  the scope via React.useId(); the pill avatar is wrapped in a
  motion.div carrying the shared-element id (dropped under reduced
  motion)
- desktop/src/features/messages/ui/TypingIndicatorRow.tsx: avatar
  extracted into a TypingAvatar sub-component that reads the scope from
  context — no new props, and typing rows rendered outside a strip
  (context null) are unaffected
- ChannelComposerActivityRow.tsx / ChannelPane.tsx: no call-site wiring
  needed beyond the strip itself (context flows through the
  typingIndicator slot)

E2E: full pill/typing strip set passes (10/10 smoke), including the
hover-freeze membership test that failed under the LayoutGroup approach.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…pill

- Normalize plan items in getActivityHeadline (agentSessionTranscriptPresentation.ts):
  the fallthrough previously returned the raw item title, so ACP plan
  updates headlined the pill as a bare "Plan" / "Plan updated" next to the
  past-tense tool verbs ("Read foo.ts", "Ran …") — now "Created plan" for
  the initial plan and "Updated plan" for update markers (keyed off isUpdate)
- Normalize thought items the same way: "Thinking" → "Thought", and the
  plan-titled thought that rendered "Planning" → "Planned"
- Assistant-message headlines (first line of the streamed reply) are
  deliberately untouched — they are content, not action labels
- Add test coverage for all four normalized cases

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…runcating at the strip cap

- BotActivityBar.tsx: apply the typing slot's max-w-64 cap only when the
  group shares the strip with working-agent pills. The cap exists so a
  long "X, Y, and N more are typing" label can't inflate the strip's
  scroll extent alongside pills, but it was unconditional — as the
  strip's LONE item a two-name label ("Alpha Debug and Beta Debug are
  typing...") got cut off at 256px even with the row mostly empty. The
  lone item already shrinks with the container (min-w-0 through the
  slot chain), so it now consumes the row's free width and only
  ellipsizes when the container is genuinely narrow.
- channels.spec.ts: add "lone typing group uses the full row width"
  e2e regression — three typers push the label past the old cap, then
  asserts no truncation at full width (scrollWidth <= clientWidth) and
  graceful ellipsizing without scroll fades at a 200px row. Verified
  the test fails against the old unconditional cap.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…rom #3151

Post-rebase integration fixes after rebasing onto main, which landed the
composer dock geometry contract (PR #3151) in the same region this branch
reworks. The rebase kept main's dock architecture (reserved bottom rail +
ComposerActivityAccessory fade) with the branch's pill strip rendered
inside it; this commit cleans up the fallout:

- ChannelPane.tsx: re-add the useChannelWorkingAgentPubkeys import — the
  dock's reserved-rail toggle (composer-dock--with-activity) still needs
  the channel-level working-set check even though
  ChannelComposerActivityRow computes its own copy — and consolidate the
  gating into a single hasComposerBottomActivity expression
- ChannelComposerActivityAccessory.tsx: delete — main's channel-level
  wrapper from #3151 is fully superseded by ChannelComposerActivityRow
  rendered inside ComposerActivityAccessory
- AppShell.tsx / routes/root.tsx: move the temporary DebugHarnessMount
  from AppShell (999/1000 lines on main, so the mount no longer fits the
  file-size ratchet) to the root route via a RootComponent wrapper
- agents/debug/README.md + DebugHarnessMount.tsx: update the removal
  instructions and header comment for the new mount location

Validated with just desktop-check, 3,817 desktop unit tests, and the
channels / composer-overflow / threadpane-ultrawide e2e specs (88
passing).

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Render the existing card-mint status chip inside the unified composer activity row so minting, ready, and failure states remain visible alongside live agent pills and typing.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Wait until the focus-thread body has a real middle-scroll range and a visible non-root anchor before switching layouts. This removes the suite-order race that left the captured anchor outside the split viewport.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Keep first-time thread-only agent typers in the combined typing group until channel-scoped observer activity provides a real session to preview. Preserve the truthful typing label after promotion and cover the full group-to-pill transition.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@tellaho
tellaho force-pushed the tho/composer-live-activity-resurrection branch from dc3a698 to cb8118e Compare August 5, 2026 23:53
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