mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user