feat(composer): unify typing indicator into the pill strip and align 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>
This commit is contained in:
Taylor Ho
2026-08-05 16:41:59 -07:00
committed by Carl
parent 33ede155c6
commit 1bb36782af
6 changed files with 189 additions and 109 deletions
@@ -25,6 +25,12 @@ type BotActivityBarProps = {
channelId?: string | null;
onOpenAgentSession: (pubkey: string, channelId?: string | null) => void;
profiles?: UserProfileLookup;
/**
* Combined typing indicator (humans + typing-fallback agents), rendered as
* the strip's trailing item — a sibling of the working pills, so it shares
* the scroller, edge fades, and layout/enter/exit animations.
*/
typingIndicator?: React.ReactNode;
workingBotPubkeys: string[];
};
@@ -285,8 +291,10 @@ function BotActivityAgentPill({
overrides the button's leading-none). Inter's ascent+descent ink is
~1.21em (~14.5px at text-xs) — taller than a leading-none line box —
and BOTH this span and the truncate span clip to their boxes, which
sheared descenders ("g", "y") off the label. */}
<span className="relative block h-4 min-w-0 flex-1 overflow-hidden leading-4">
sheared descenders
("g", "y") off the label. translate-y-px mirrors the typing
indicator label's optical nudge so both baselines line up. */}
<span className="relative block h-4 min-w-0 flex-1 translate-y-px overflow-hidden leading-4">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
animate={{ y: 0 }}
@@ -323,7 +331,7 @@ function BotActivityAgentPill({
<PopoverTrigger asChild>
<button
aria-label={`${agent.name} is working. View activity.`}
className="inline-flex h-7 min-w-0 max-w-50 items-center gap-1.5 rounded-full border border-border/60 bg-background pl-0.75 pr-2 text-xs font-semibold leading-none text-muted-foreground shadow-xs transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:border-primary/40 data-[state=open]:bg-primary/10 data-[state=open]:text-primary"
className="inline-flex h-7 min-w-0 max-w-50 items-center gap-2 rounded-full text-xs font-medium leading-none text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring data-[state=open]:text-primary"
data-testid="bot-activity-composer-trigger"
onBlur={hover.scheduleClose}
onClick={(event) => {
@@ -450,7 +458,10 @@ function AnimatedPillSlot({
/**
* Composer status strip for working agents: one pill per working agent,
* sharing one strip-level hover popover (at most one card open). Pills are
* sharing one strip-level hover popover (at most one card open), plus the
* combined typing indicator as the strip's trailing item — a slot sibling of
* the pills, so it scrolls with them, clips under the same edge fades, and
* enters/exits/moves with the same slot animations. Pills are
* ordered by turn start (earliest worker left-most) — a stable slot for the
* whole turn, so liveness is carried by each pill's label ticker while
* positional motion is reserved for membership changes: a newly working
@@ -473,6 +484,7 @@ export function BotActivityComposerAction({
channelId = null,
onOpenAgentSession,
profiles,
typingIndicator,
workingBotPubkeys,
}: BotActivityBarProps) {
const shouldReduceMotion = useReducedMotion();
@@ -552,10 +564,15 @@ export function BotActivityComposerAction({
);
}, [holdActive, liveOrderedAgents]);
if (orderedAgents.length === 0) {
if (orderedAgents.length === 0 && typingIndicator == null) {
return null;
}
// A slot only shrinks with the container when it is the strip's LONE item;
// otherwise items keep their natural width and the strip scrolls under the
// edge fades.
const itemCount = orderedAgents.length + (typingIndicator == null ? 0 : 1);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: hover-only hold — keyboard focus drives the same hold via the pill triggers.
<div
@@ -592,7 +609,7 @@ export function BotActivityComposerAction({
freezeLayout={holdActive}
key={agent.pubkey}
shouldReduceMotion={Boolean(shouldReduceMotion)}
shrinkToFit={orderedAgents.length === 1}
shrinkToFit={itemCount === 1}
>
{(isMoving) => (
<BotActivityAgentPill
@@ -610,6 +627,34 @@ export function BotActivityComposerAction({
)}
</AnimatedPillSlot>
))}
{typingIndicator == null ? null : (
<AnimatedPillSlot
freezeLayout={holdActive}
key="typing"
shouldReduceMotion={Boolean(shouldReduceMotion)}
shrinkToFit={itemCount === 1}
>
{() => (
<div
className={cn(
// Cap like the pills (which use max-w-50 each) so a
// long "X, Y, and N more are typing" label truncates
// instead of inflating the scroll extent.
"flex h-7 min-w-0 max-w-64 items-center",
// Composer-edge alignment when the typing group leads
// the strip (no pills): 0.75rem/1rem composer padding
// + 1px border. Mirrors the standalone row's old
// inset, and now animates via the slot's layout spring
// when pills come and go.
orderedAgents.length === 0 && "pl-3.25 sm:pl-4.25",
)}
data-testid="bot-activity-typing-slot"
>
{typingIndicator}
</div>
)}
</AnimatedPillSlot>
)}
</AnimatePresence>
</div>
</motion.div>
@@ -12,11 +12,12 @@ import {
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
/**
* Status strip anchored directly below the message composer: the inline
* "agents working" trigger plus the typing indicator.
* Status strip anchored directly below the message composer: the working
* agent pills plus the typing indicator, rendered as slot siblings inside
* ONE strip (BotActivityComposerAction) so both share the scroller, edge
* fades, and layout/enter/exit animations.
*
* The working set splits by signal source: observer-backed agents get the
* interactive activity pills, while typing-fallback-only agents (no observer
@@ -110,42 +111,34 @@ export function ChannelComposerActivityRow({
className="h-8.5 overflow-visible bg-background px-5 pb-1.5 pt-0"
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; 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}
profiles={profiles}
workingBotPubkeys={observerWorkingPubkeys}
/>
</div>
) : null}
{combinedTypingPubkeys.length > 0 ? (
<TypingIndicatorRow
channel={channel}
className={cn(
"min-w-0 flex-1 py-0 pr-0",
// Composer-edge alignment only when the typing group leads the
// row; next to pills the row's gap is the whole spacing (the
// base variant's px-4/sm:px-6 must be zeroed, not just left
// unoverridden).
observerWorkingPubkeys.length === 0
? "pl-[calc(0.75rem+1px)] sm:pl-[calc(1rem+1px)]"
: "pl-0 sm:pl-0",
)}
currentPubkey={currentPubkey}
// Match the activity pills' label weight so the two read as one
// strip.
labelClassName="font-semibold"
profiles={typingProfiles}
typingPubkeys={combinedTypingPubkeys}
<div className="flex h-full w-full items-center overflow-visible">
{/* One strip hosts both groups: working pills plus the typing group
as the strip's trailing slot sibling, so they share the scroller,
edge fades, and layout/enter/exit animations. When the row gets
tight the strip scrolls horizontally (edge fades signal clipped
items) rather than compressing. */}
{observerWorkingPubkeys.length > 0 ||
combinedTypingPubkeys.length > 0 ? (
<BotActivityComposerAction
agents={agents}
channelId={channelId}
onOpenAgentSession={onOpenAgentSession}
profiles={profiles}
typingIndicator={
combinedTypingPubkeys.length > 0 ? (
<TypingIndicatorRow
channel={channel}
// The strip's slot owns spacing and the typing-only inset;
// zero the base paddings and let the row shrink so the
// lone-item slot can ellipsize the label.
className="min-w-0 shrink px-0 py-0 sm:px-0"
currentPubkey={currentPubkey}
profiles={typingProfiles}
typingPubkeys={combinedTypingPubkeys}
/>
) : null
}
workingBotPubkeys={observerWorkingPubkeys}
/>
) : null}
</div>
@@ -41,6 +41,7 @@ import { useCardMintJobs } from "@/features/agents/cardMintStore";
import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
import { ChannelComposerActivityRow } from "@/features/channels/ui/ChannelComposerActivityRow";
import { ComposerActivityAccessory } from "@/features/messages/ui/ComposerActivityAccessory";
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
import {
containsWelcomePersonaMention,
WelcomeComposerBanner,
@@ -893,15 +894,33 @@ export const ChannelPane = React.memo(function ChannelPane({
threadHeadMessage.id,
)}
threadReplyUnreadCounts={threadReplyUnreadCounts}
threadTypingPubkeys={threadTypingPubkeys}
activityAccessoryVisible={hasThreadComposerBotActivity}
activityAccessoryVisible={
hasThreadComposerBotActivity ||
threadTypingPubkeys.length > 0
}
activityAccessoryContent={
hasThreadComposerBotActivity ? (
hasThreadComposerBotActivity ||
threadTypingPubkeys.length > 0 ? (
<BotActivityComposerAction
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
profiles={profiles}
typingIndicator={
threadTypingPubkeys.length > 0 ? (
<TypingIndicatorRow
channel={activeChannel}
// The strip's slot owns spacing and the
// typing-only inset; zero the base paddings and
// let the row shrink so the lone-item slot can
// ellipsize the label.
className="min-w-0 shrink px-0 py-0 sm:px-0"
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={threadTypingPubkeys}
/>
) : null
}
workingBotPubkeys={threadComposerBotTypingPubkeys}
/>
) : null
@@ -45,7 +45,6 @@ import { MessageComposer } from "./MessageComposer";
import { ThreadMessageSkeleton } from "./MessageThreadPanelSkeleton";
import { MessageRow, type ThreadDepthGuideAction } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { TypingIndicatorRow } from "./TypingIndicatorRow";
import { UnreadDivider } from "./UnreadDivider";
import { useComposerHeightPadding } from "./useComposerHeightPadding";
import { useAnchoredScroll } from "./useAnchoredScroll";
@@ -110,7 +109,6 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
threadRepliesPending?: boolean;
threadUnreadCount?: number;
threadReplyUnreadCounts?: ReadonlyMap<string, number>;
threadTypingPubkeys: string[];
videoReviewContextsByMessageId?: ReadonlyMap<string, VideoReviewContext>;
activityAccessoryContent?: React.ReactNode;
activityAccessoryVisible: boolean;
@@ -230,7 +228,6 @@ export function MessageThreadPanel({
threadRepliesPending = false,
threadUnreadCount,
threadReplyUnreadCounts,
threadTypingPubkeys,
activityAccessoryContent,
activityAccessoryVisible,
widthPx,
@@ -255,9 +252,9 @@ export function MessageThreadPanel({
);
const hasConstrainedColumn = columnMaxWidthPx != null;
// Whether the composer dock trades its quiet-state spacer for the
// conditional activity accessory (agent working and/or someone typing).
const hasComposerBottomActivity =
activityAccessoryVisible || threadTypingPubkeys.length > 0;
// conditional activity accessory (agent working and/or someone typing —
// ChannelPane folds thread typers into the strip and its visibility).
const hasComposerBottomActivity = activityAccessoryVisible;
// Live ref so onCaptureSendContext can read reply state at submit time
// (before any async mention-flow awaits change navigation state).
@@ -916,33 +913,11 @@ export function MessageThreadPanel({
{/* No mx-auto/max-w column here: the composer above spans the
full pane width, so the activity row must too or the typing
group floats toward the pane's center. Mirrors
ChannelComposerActivityRow: the pill strip sizes to its
content and the typing group takes the remainder, instead of
splitting the row 50/50. */}
<div className="flex w-full items-center gap-3 overflow-visible">
{activityAccessoryVisible && activityAccessoryContent ? (
<div className="flex min-w-0 overflow-visible">
{activityAccessoryContent}
</div>
) : null}
{threadTypingPubkeys.length > 0 ? (
<TypingIndicatorRow
channel={channel}
className={cn(
"min-w-0 flex-1 py-0 pr-0",
// Composer-edge alignment only when the typing group
// leads the row; next to pills the row's gap is the
// whole spacing.
activityAccessoryVisible && activityAccessoryContent
? "pl-0 sm:pl-0"
: "pl-[calc(0.75rem+1px)] sm:pl-[calc(1rem+1px)]",
)}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={threadTypingPubkeys}
variant="activity"
/>
) : null}
ChannelComposerActivityRow: ONE strip hosts the pills and the
typing group as slot siblings (the strip is built by
ChannelPane, thread typers included). */}
<div className="flex w-full items-center overflow-visible">
{activityAccessoryContent}
</div>
</ComposerActivityAccessory>
</div>
@@ -18,7 +18,6 @@ type TypingIndicatorRowProps = {
labelClassName?: string;
profiles?: UserProfileLookup;
typingPubkeys: string[];
variant?: "default" | "activity";
};
function resolveFallbackName(channel: Channel | null, pubkey: string) {
@@ -60,9 +59,7 @@ export function TypingIndicatorRow({
labelClassName,
profiles,
typingPubkeys,
variant = "default",
}: TypingIndicatorRowProps) {
const isActivityVariant = variant === "activity";
const labels = React.useMemo(
() =>
typingPubkeys.map((pubkey) =>
@@ -80,22 +77,13 @@ export function TypingIndicatorRow({
return (
<div
aria-live="polite"
className={cn(
"shrink-0 bg-transparent",
isActivityVariant ? "flex items-center px-0 py-0" : "px-4 py-2 sm:px-6",
className,
)}
className={cn("shrink-0 bg-transparent px-4 py-2 sm:px-6", className)}
{...(labels.length > 0
? { "data-testid": "message-typing-indicator" }
: {})}
>
{labels.length > 0 && (
<div
className={cn(
"flex min-w-0 w-full items-center",
isActivityVariant ? "h-full gap-1.5" : "gap-2",
)}
>
<div className="flex min-w-0 w-full items-center gap-2">
<div className="flex shrink-0 items-center">
{typingPubkeys.map((pubkey, index) => {
const profile = profiles?.[pubkey.toLowerCase()];
@@ -104,8 +92,7 @@ export function TypingIndicatorRow({
<div
key={pubkey}
className={cn(
"relative shrink-0 rounded-lg ring-1 ring-background",
isActivityVariant ? "h-4 w-4" : "h-5 w-5",
"relative h-5 w-5 shrink-0 rounded-lg ring-1 ring-background",
index > 0 && "-ml-1.5",
)}
data-testid="message-typing-avatar"
@@ -113,14 +100,8 @@ export function TypingIndicatorRow({
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
label={label}
className={cn(
isActivityVariant
? "h-4 w-4 text-3xs"
: "h-5 w-5 text-3xs",
)}
iconClassName={
isActivityVariant ? "h-2.5 w-2.5" : "h-4 w-4"
}
className="h-5 w-5 text-3xs"
iconClassName="h-4 w-4"
/>
</div>
);
@@ -128,10 +109,7 @@ export function TypingIndicatorRow({
</div>
<p
className={cn(
"min-w-0 translate-y-px truncate text-muted-foreground",
isActivityVariant
? "text-2xs font-medium leading-3"
: "text-xs font-medium leading-4",
"min-w-0 translate-y-px truncate text-xs font-medium leading-4 text-muted-foreground",
labelClassName,
)}
data-testid="message-typing-indicator-label"
+70
View File
@@ -2597,6 +2597,76 @@ test("narrow strip scrolls horizontally with edge fades instead of compressing p
}
});
test("typing group renders inside the pill strip and scrolls under the edge fades", async ({
page,
}) => {
await installMockBridge(page, { managedAgents: PILL_AGENT_SEEDS });
await openAgentsChannelWithTwoWorkingPills(page);
await waitForMockLiveSubscription(page, "agents", KIND_TYPING_INDICATOR);
// A human typer joins the strip as the trailing slot sibling of the
// pills — same scroller, same edge fades, same slot animations — instead
// of a separate row sibling outside the scroll viewport.
await page.evaluate((pubkey) => {
window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({
channelName: "agents",
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
const scroller = page.getByTestId("bot-activity-strip-scroller");
const typingIndicator = scroller.getByTestId("message-typing-indicator");
await expect(typingIndicator).toBeVisible();
await expect(
scroller.getByTestId("message-typing-indicator-label"),
).toContainText("alice is typing");
// Trailing slot: the typing group sits after the last pill.
const lastPillBox = await page
.getByTestId("bot-activity-composer-trigger")
.last()
.boundingBox();
const typingBox = await typingIndicator.boundingBox();
expect(typingBox).not.toBeNull();
expect(lastPillBox).not.toBeNull();
if (typingBox && lastPillBox) {
expect(typingBox.x).toBeGreaterThanOrEqual(
lastPillBox.x + lastPillBox.width,
);
}
// Constrained, the strip overflows into scroll with the trailing fade:
// the typing group is part of the scrollable content and clips under the
// fade rather than holding a reserved spot outside the scroller.
await page
.getByTestId("channel-composer-activity-row")
.evaluate((element) => {
element.style.width = "300px";
});
await expect
.poll(() => scroller.evaluate((el) => el.scrollWidth - el.clientWidth))
.toBeGreaterThan(0);
await expect(page.getByTestId("bot-activity-strip-fade-end")).toBeVisible();
// Scrolled to the far end, the typing group's right edge lands inside the
// row — it scrolled WITH the pills.
await scroller.evaluate((el) => {
el.scrollLeft = el.scrollWidth;
});
await expect(page.getByTestId("bot-activity-strip-fade-start")).toBeVisible();
const constrainedRowBox = await page
.getByTestId("channel-composer-activity-row")
.boundingBox();
const scrolledTypingBox = await typingIndicator.boundingBox();
expect(scrolledTypingBox).not.toBeNull();
expect(constrainedRowBox).not.toBeNull();
if (scrolledTypingBox && constrainedRowBox) {
expect(scrolledTypingBox.x + scrolledTypingBox.width).toBeLessThanOrEqual(
constrainedRowBox.x + constrainedRowBox.width + 1,
);
}
});
test("lone pill shrinks to fit a narrow container without scroll fades", async ({
page,
}) => {