diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index dd116c29b..31e0bfc7a 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -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, +) { + 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(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({ { // 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 ? ( - openSession()} - profiles={profiles} - /> - ) : ( - - )} + openSession()} + profiles={profiles} + /> ); @@ -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 ( (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.
setBarHovered(true)} onMouseLeave={() => setBarHovered(false)} > - - {orderedAgents.map((agent) => ( - - {(isMoving) => ( - - )} - - ))} - + {/* 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. */} + +
+ + {orderedAgents.map((agent) => ( + + {(isMoving) => ( + + )} + + ))} + +
+
+ {/* 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 ? ( +
+ ) : null} + {fades.end ? ( +
+ ) : null}
); } diff --git a/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx b/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx index d55090046..dfe176af0 100644 --- a/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx +++ b/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx @@ -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" >
- {/* 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 ? (
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index c8938049d..6544a7aac 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -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} /> diff --git a/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx b/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx index eeaea1523..a76c090ab 100644 --- a/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx +++ b/desktop/src/features/channels/ui/ComposerLiveActivityFeed.tsx @@ -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 diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index c5cf0ef83..0b6769841 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -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, }) => { diff --git a/preview-features.json b/preview-features.json index de4c61246..388f1c39b 100644 --- a/preview-features.json +++ b/preview-features.json @@ -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"] } ] }