feat(composer): ship live activity by default and scroll the pill strip 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>
This commit is contained in:
Taylor Ho
2026-08-05 16:41:57 -07:00
committed by Carl
parent df164431c7
commit 3e597d003b
6 changed files with 235 additions and 97 deletions
@@ -1,5 +1,4 @@
import * as React from "react";
import { Loader2 } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
@@ -9,7 +8,6 @@ import {
import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ManagedAgent } from "@/shared/api/types";
import { useFeatureEnabled } from "@/shared/features";
import { cn } from "@/shared/lib/cn";
import { useNow } from "@/shared/lib/useNow";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
@@ -27,7 +25,6 @@ type BotActivityBarProps = {
agents: BotActivityAgent[];
channelId?: string | null;
onOpenAgentSession: (pubkey: string, channelId?: string | null) => void;
openAgentSessionPubkey: string | null;
profiles?: UserProfileLookup;
workingBotPubkeys: string[];
};
@@ -78,6 +75,52 @@ type StripHoverPopover = {
scheduleClose: () => void;
};
/**
* Edge-fade state for the horizontally scrollable pill strip: which sides
* currently have pills clipped out of view. Tracks the scroller's scroll
* position plus size changes of both the scroller (container narrows —
* thread panel resize) and its content wrapper (pills entering, leaving, or
* relabeling to a different width), so the fades appear and disappear
* without a re-render triggering event.
*/
function useStripOverflowFades(
scrollerRef: React.RefObject<HTMLDivElement | null>,
) {
const [fades, setFades] = React.useState({ end: false, start: false });
const updateFades = React.useCallback(() => {
const node = scrollerRef.current;
if (node === null) {
return;
}
// 1px slack absorbs sub-pixel rounding in scrollWidth/clientWidth.
const start = node.scrollLeft > 1;
const end = node.scrollLeft + node.clientWidth < node.scrollWidth - 1;
setFades((current) =>
current.start === start && current.end === end ? current : { end, start },
);
}, [scrollerRef]);
React.useEffect(() => {
const node = scrollerRef.current;
if (node === null) {
return;
}
updateFades();
const observer = new ResizeObserver(updateFades);
observer.observe(node);
// The content wrapper is the scroller's only child; observing it catches
// overflow changes that don't touch the scroller's own box.
const content = node.firstElementChild;
if (content !== null) {
observer.observe(content);
}
return () => observer.disconnect();
}, [scrollerRef, updateFades]);
return { fades, updateFades };
}
function useStripHoverPopover(): StripHoverPopover {
const [activePubkey, setActivePubkey] = React.useState<string | null>(null);
// Ref mirror so pointer handlers can branch on open-vs-closed synchronously.
@@ -158,9 +201,7 @@ function useStripHoverPopover(): StripHoverPopover {
* out — deferred until the pill's slot settles when a reorder is in flight.
* Hovering shows the agent's live activity feed as the popover surface
* itself — flat, no inset box, no tab strip — while clicking the pill opens
* the agent's full runtime in the auxiliary panel. With the
* `composerLiveActivity` preview flag off, the hover popover keeps the
* legacy "View activity" item instead.
* the agent's full runtime in the auxiliary panel.
*
* Only observer-backed agents reach this pill: typing-fallback-only agents
* are diverted into the combined typing indicator group by
@@ -172,9 +213,7 @@ function BotActivityAgentPill({
channelId,
holdLabelSwap,
hover,
liveActivityEnabled,
onOpenAgentSession,
openAgentSessionPubkey,
pinWidth,
profiles,
}: {
@@ -185,9 +224,7 @@ function BotActivityAgentPill({
holdLabelSwap: boolean;
/** Strip-level hover popover state shared by every pill. */
hover: StripHoverPopover;
liveActivityEnabled: boolean;
onOpenAgentSession: (pubkey: string, channelId?: string | null) => void;
openAgentSessionPubkey: string | null;
/** Freeze the pill's rendered width (a hover card is showing). */
pinWidth: boolean;
profiles?: UserProfileLookup;
@@ -223,8 +260,6 @@ function BotActivityAgentPill({
: { id: activeId, label: activeLabel },
);
}, [holdLabelSwap, activeId, activeLabel]);
const isSessionOpen =
openAgentSessionPubkey?.toLowerCase() === agent.pubkey.toLowerCase();
// While a hover card is showing, the pill must not resize: label swaps
// keep animating, but a longer/shorter label truncating inside a FROZEN
@@ -322,9 +357,7 @@ function BotActivityAgentPill({
</PopoverTrigger>
<PopoverContent
align="start"
className={cn(
liveActivityEnabled ? "w-80 overflow-hidden p-0" : "w-64 p-1",
)}
className="w-80 overflow-hidden p-0"
onCloseAutoFocus={(event) => {
// A hover preview must not yank focus back to the trigger on
// close: the trigger's focus handler would re-open the card.
@@ -336,39 +369,13 @@ function BotActivityAgentPill({
side="top"
sideOffset={8}
>
{liveActivityEnabled ? (
<ComposerLiveActivityFeed
agent={agent}
channelId={channelId}
className="h-48 rounded-[inherit]"
onOpenAgentSession={() => openSession()}
profiles={profiles}
/>
) : (
<button
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors",
isSessionOpen
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={`bot-activity-composer-item-${agent.pubkey}`}
onClick={openSession}
type="button"
>
<UserAvatar
avatarUrl={avatarUrl}
className="shrink-0"
displayName={agent.name}
size="sm"
/>
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
<span className="shrink-0 whitespace-nowrap text-xs font-medium opacity-80">
View activity
</span>
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground/70" />
</button>
)}
<ComposerLiveActivityFeed
agent={agent}
channelId={channelId}
className="h-48 rounded-[inherit]"
onOpenAgentSession={() => openSession()}
profiles={profiles}
/>
</PopoverContent>
</Popover>
);
@@ -387,12 +394,19 @@ function AnimatedPillSlot({
children,
freezeLayout,
shouldReduceMotion,
shrinkToFit,
}: {
/** Render prop so the pill can defer label swaps while its slot moves. */
children: (isMoving: boolean) => React.ReactNode;
/** Disable slot layout animation while a hover card is showing. */
freezeLayout: boolean;
shouldReduceMotion: boolean;
/**
* Lone pill: shrink with the container (label ellipsizes) instead of
* overflowing into scroll — an edge fade over a single pill reads as a
* cut-off bug, not an affordance.
*/
shrinkToFit: boolean;
}) {
const [isMoving, setIsMoving] = React.useState(false);
@@ -408,7 +422,12 @@ function AnimatedPillSlot({
return (
<motion.div
animate={{ opacity: isMoving ? [1, 0.35, 1] : 1, scale: 1 }}
className="flex min-w-0"
// With several pills, shrink-0 keeps each at its natural
// (label-truncated) width so a strip that outgrows a narrow container
// scrolls — with the edge fade signalling the pills off view —
// instead of compressing every pill into an unreadable sliver. A lone
// pill instead shrinks to fit (min-w-0) so it never scrolls.
className={cn("flex", shrinkToFit ? "min-w-0" : "shrink-0")}
exit={{ opacity: 0, scale: 0.9 }}
initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.9 }}
layout={!shouldReduceMotion && !freezeLayout}
@@ -441,18 +460,21 @@ function AnimatedPillSlot({
* slot as the order changes — except while the cursor is over the bar or a
* hover card is showing, when order, membership, layout animation, and pill
* widths are all frozen so nothing can move out from under (or slide under)
* the cursor. Pills shrink and truncate their labels when several agents
* work at once so the strip never wraps the fixed-height row.
* the cursor. Each pill truncates its label at its own max width. A lone
* pill shrinks with a narrow container (its label ellipsizes) so it always
* fits; when SEVERAL pills outgrow a narrow container (thread panel
* toolbars especially) the strip scrolls horizontally — scrollbar hidden,
* with edge gradient fades signalling the pills off view — instead of
* compressing every pill into an unreadable sliver or wrapping the
* fixed-height row.
*/
export function BotActivityComposerAction({
agents,
channelId = null,
onOpenAgentSession,
openAgentSessionPubkey,
profiles,
workingBotPubkeys,
}: BotActivityBarProps) {
const liveActivityEnabled = useFeatureEnabled("composerLiveActivity");
const shouldReduceMotion = useReducedMotion();
const hover = useStripHoverPopover();
// The freeze engages as soon as the cursor is anywhere over the bar — not
@@ -463,6 +485,9 @@ export function BotActivityComposerAction({
const [barHovered, setBarHovered] = React.useState(false);
const holdActive = barHovered || hover.activePubkey !== null;
const scrollerRef = React.useRef<HTMLDivElement | null>(null);
const { fades, updateFades } = useStripOverflowFades(scrollerRef);
const workingAgents = React.useMemo(() => {
const workingSet = new Set(
workingBotPubkeys.map((pubkey) => pubkey.toLowerCase()),
@@ -530,37 +555,68 @@ export function BotActivityComposerAction({
return (
// biome-ignore lint/a11y/noStaticElementInteractions: hover-only hold — keyboard focus drives the same hold via the pill triggers.
<div
className="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible"
className="relative min-w-0 flex-1"
data-testid="bot-activity-strip"
onMouseEnter={() => setBarHovered(true)}
onMouseLeave={() => setBarHovered(false)}
>
<AnimatePresence initial={false}>
{orderedAgents.map((agent) => (
<AnimatedPillSlot
freezeLayout={holdActive}
key={agent.pubkey}
shouldReduceMotion={Boolean(shouldReduceMotion)}
>
{(isMoving) => (
<BotActivityAgentPill
agent={agent}
avatarUrl={
profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null
}
channelId={channelId}
holdLabelSwap={isMoving}
hover={hover}
liveActivityEnabled={liveActivityEnabled}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
pinWidth={holdActive}
profiles={profiles}
/>
)}
</AnimatedPillSlot>
))}
</AnimatePresence>
{/* layoutScroll keeps the slot layout animations correct while the
strip is scrolled — motion measures positions relative to the
scroll offset instead of jumping. The negative-margin/padding pair
gives focus rings and pill shadows room inside the clip box
without changing the row's height. */}
<motion.div
className="-my-1 flex items-center overflow-x-auto overscroll-x-contain py-1 scrollbar-none [&::-webkit-scrollbar]:hidden"
data-testid="bot-activity-strip-scroller"
layoutScroll
onScroll={updateFades}
ref={scrollerRef}
>
<div className="flex min-w-0 items-center gap-1.5">
<AnimatePresence initial={false}>
{orderedAgents.map((agent) => (
<AnimatedPillSlot
freezeLayout={holdActive}
key={agent.pubkey}
shouldReduceMotion={Boolean(shouldReduceMotion)}
shrinkToFit={orderedAgents.length === 1}
>
{(isMoving) => (
<BotActivityAgentPill
agent={agent}
avatarUrl={
profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null
}
channelId={channelId}
holdLabelSwap={isMoving}
hover={hover}
onOpenAgentSession={onOpenAgentSession}
pinWidth={holdActive}
profiles={profiles}
/>
)}
</AnimatedPillSlot>
))}
</AnimatePresence>
</div>
</motion.div>
{/* Edge fades: obvious "more pills off view" affordance. Rendered only
for a side that actually has clipped content, so they never dim a
fully visible strip. */}
{fades.start ? (
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-linear-to-r from-background via-background/60 to-transparent"
data-testid="bot-activity-strip-fade-start"
/>
) : null}
{fades.end ? (
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-linear-to-l from-background via-background/60 to-transparent"
data-testid="bot-activity-strip-fade-end"
/>
) : null}
</div>
);
}
@@ -35,7 +35,6 @@ export function ChannelComposerActivityRow({
channel,
currentPubkey,
onOpenAgentSession,
openAgentSessionPubkey,
profiles,
typingPubkeys,
}: {
@@ -43,7 +42,6 @@ export function ChannelComposerActivityRow({
channel: Channel | null;
currentPubkey?: string;
onOpenAgentSession: (pubkey: string, channelId?: string | null) => void;
openAgentSessionPubkey: string | null;
profiles?: UserProfileLookup;
typingPubkeys: string[];
}) {
@@ -113,16 +111,17 @@ export function ChannelComposerActivityRow({
data-testid="channel-composer-activity-row"
>
<div className="flex h-full w-full items-center gap-3 overflow-visible">
{/* The pill strip sizes to its content (shrinkable when the row gets
tight); the typing group takes whatever is left so it sits
directly after the pills instead of splitting the row 50/50. */}
{/* The pill strip sizes to its content; when the row gets tight it
scrolls horizontally (edge fades signal clipped pills) rather
than compressing. The typing group takes whatever is left so it
sits directly after the pills instead of splitting the row
50/50. */}
{observerWorkingPubkeys.length > 0 ? (
<div className="flex min-w-0 overflow-visible">
<BotActivityComposerAction
agents={agents}
channelId={channelId}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
workingBotPubkeys={observerWorkingPubkeys}
/>
@@ -804,7 +804,6 @@ export const ChannelPane = React.memo(function ChannelPane({
channel={activeChannel}
currentPubkey={currentPubkey}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
@@ -902,7 +901,6 @@ export const ChannelPane = React.memo(function ChannelPane({
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
workingBotPubkeys={threadComposerBotTypingPubkeys}
/>
@@ -16,7 +16,7 @@ import { deriveLastLiveAt } from "./composerLiveActivity";
/**
* Single-agent live activity preview for the composer "agents working"
* popover. Preview-gated behind the `composerLiveActivity` feature.
* popover.
*
* Renders the selected working agent's channel-scoped transcript with the
* same compact primitive as the profile activity embed
+93 -2
View File
@@ -2014,8 +2014,7 @@ test("shows and clears activity indicators for active channel agents", async ({
const activityPill = page.getByTestId("bot-activity-composer-trigger");
await expect(activityPill).toBeVisible();
await expect(activityPill).not.toContainText("View activity");
// E2E seeds every preview feature on, so the composerLiveActivity path is
// what renders here: hovering the pill opens the live-activity preview.
// Hovering the pill opens the live-activity preview.
await activityPill.hover();
await expect(page.getByTestId("composer-live-activity-feed")).toBeVisible();
// Clicking the pill promotes the agent's runtime into the aux panel.
@@ -2364,6 +2363,98 @@ test("hovering across pills switches a single live-activity card", async ({
);
});
test("narrow strip scrolls horizontally with edge fades instead of compressing pills", async ({
page,
}) => {
await installMockBridge(page, { managedAgents: PILL_AGENT_SEEDS });
await openAgentsChannelWithTwoWorkingPills(page);
// Constrain the activity row directly (instead of resizing the whole
// window) so the overflow condition is deterministic regardless of the
// app's responsive layout — this mirrors a narrow thread panel, where the
// same strip renders in the thread composer toolbar.
await page
.getByTestId("channel-composer-activity-row")
.evaluate((element) => {
element.style.width = "300px";
});
const scroller = page.getByTestId("bot-activity-strip-scroller");
await expect
.poll(() => scroller.evaluate((el) => el.scrollWidth - el.clientWidth))
.toBeGreaterThan(0);
// Pills keep a readable natural width instead of compressing into
// slivers to fit.
const triggers = page.getByTestId("bot-activity-composer-trigger");
const firstBox = await triggers.first().boundingBox();
expect(firstBox?.width ?? 0).toBeGreaterThan(120);
// At rest the strip is pinned to the start: clipped content (and its
// fade affordance) on the trailing side only.
await expect(page.getByTestId("bot-activity-strip-fade-end")).toBeVisible();
await expect(page.getByTestId("bot-activity-strip-fade-start")).toHaveCount(
0,
);
// Scrolling to the far end swaps the fades to the leading side.
await scroller.evaluate((el) => {
el.scrollLeft = el.scrollWidth;
});
await expect(page.getByTestId("bot-activity-strip-fade-start")).toBeVisible();
await expect(page.getByTestId("bot-activity-strip-fade-end")).toHaveCount(0);
});
test("lone pill shrinks to fit a narrow container without scroll fades", async ({
page,
}) => {
await installMockBridge(page, { managedAgents: PILL_AGENT_SEEDS });
await page.goto("/");
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
await page.waitForFunction(
() => typeof window.__BUZZ_E2E_SEED_ACTIVE_TURNS__ === "function",
null,
{ timeout: 10_000 },
);
await page.evaluate(
({ agentPubkey, channelId }) => {
window.__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({
agentPubkey,
channelId,
turnId: "lone-pill-turn",
});
},
{ agentPubkey: PILL_AGENT_NOVA, channelId: AGENTS_CHANNEL_ID },
);
const trigger = page.getByTestId("bot-activity-composer-trigger");
await expect(trigger).toHaveCount(1);
// Narrower than the pill's natural width: a lone pill must shrink and
// ellipsize rather than overflow into scroll — an edge fade over a single
// pill reads as a cut-off bug, not a "more pills off view" affordance.
const row = page.getByTestId("channel-composer-activity-row");
await row.evaluate((element) => {
element.style.width = "160px";
});
const scroller = page.getByTestId("bot-activity-strip-scroller");
await expect
.poll(() => scroller.evaluate((el) => el.scrollWidth - el.clientWidth))
.toBe(0);
await expect(page.getByTestId("bot-activity-strip-fade-end")).toHaveCount(0);
await expect(page.getByTestId("bot-activity-strip-fade-start")).toHaveCount(
0,
);
const pillBox = await trigger.boundingBox();
const rowBox = await row.boundingBox();
expect(pillBox?.width ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual(
rowBox?.width ?? 0,
);
});
test("members sidebar exposes view-activity for a viewer-owned relay agent", async ({
page,
}) => {
-6
View File
@@ -30,12 +30,6 @@
"name": "Agent-managed profiles",
"description": "Let agents manage their own relay name and avatar instead of restoring the desktop copy",
"platforms": ["desktop"]
},
{
"id": "composerLiveActivity",
"name": "Composer live activity",
"description": "Live activity preview for the selected working agent in the agents-working popover below the chat composer",
"platforms": ["desktop"]
}
]
}