diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index ddd4bf99fe..24951ae121 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -19,6 +19,11 @@ import { import { Shimmer } from "@/shared/ui/Shimmer"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { ComposerLiveActivityFeed } from "./ComposerLiveActivityFeed"; +import { + COMPOSER_STRIP_AVATAR_MORPH_TRANSITION, + ComposerStripAvatarScopeContext, + useComposerStripAvatarLayoutId, +} from "./composerStripAvatarLayout"; import { deriveActivityPillLabel, deriveAgentWorkingOrder, @@ -255,6 +260,7 @@ function BotActivityAgentPill({ const pillKey = agent.pubkey.toLowerCase(); const open = hover.activePubkey === pillKey; const shouldReduceMotion = useReducedMotion(); + const avatarMorphLayoutId = useComposerStripAvatarLayoutId(agent.pubkey); const transcript = useAgentTranscript(true, agent.pubkey); const headline = React.useMemo( () => deriveActivityPillLabel({ channelId, transcript }), @@ -325,12 +331,22 @@ function BotActivityAgentPill({ const pillContent = ( <> - + {/* Shared-element home: matches the typing group's avatar layoutId so + a group→pill promotion morphs the avatar across (see + composerStripAvatarLayout). Under reduced motion the id is dropped + and the avatar mounts in place. */} + + + {/* Ticker viewport: 16px tall with a matching 16px line box (leading-4 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 — @@ -557,7 +573,9 @@ function AnimatedPillSlot({ * compressing every pill into an unreadable sliver or wrapping the * fixed-height row. The scroll viewport bleeds across the mounting row's * gutter so the clip line and its fades sit at the container's visual - * edges, never mid-row on a pill. + * edges, never mid-row on a pill. The strip provides a per-instance avatar + * shared-element scope (composerStripAvatarLayout) so an agent's avatar + * morphs between the typing group and a pill on partition flips. */ export function BotActivityComposerAction({ agents, @@ -569,6 +587,10 @@ export function BotActivityComposerAction({ }: BotActivityBarProps) { const shouldReduceMotion = useReducedMotion(); const hover = useStripHoverPopover(); + // Namespaces the avatar shared-element ids to THIS strip: the channel + // composer strip and the thread panel strip can be mounted at once, and a + // morph must never travel between them (see composerStripAvatarLayout). + const avatarScopeId = React.useId(); // The MEMBERSHIP freeze engages as soon as the cursor is anywhere over the // bar — not only once a card opens — so pills can't enter/exit and slide // the strip under a cursor that is still traveling toward one. It also @@ -670,132 +692,134 @@ export function BotActivityComposerAction({ 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. -
setBarHovered(true)} - onMouseLeave={() => setBarHovered(false)} - > - {/* layoutScroll keeps the slot layout animations correct while the + + {/* biome-ignore lint/a11y/noStaticElementInteractions: hover-only hold — keyboard focus drives the same hold via the pill triggers. */} +
setBarHovered(true)} + onMouseLeave={() => setBarHovered(false)} + > + {/* 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. px-5 restores the row gutter inside the full-bleed viewport (see the wrapper above). */} - -
- - {orderedAgents.map((agent, index) => { - const guardsOpenCard = - activeCardIndex !== -1 && index <= activeCardIndex; - return ( +
+ + {orderedAgents.map((agent, index) => { + const guardsOpenCard = + activeCardIndex !== -1 && index <= activeCardIndex; + return ( + + {(isMoving) => ( + + )} + + ); + })} + {typingIndicator == null ? null : ( - {(isMoving) => ( - + {() => ( +
+ {typingIndicator} +
)}
- ); - })} - {typingIndicator == null ? null : ( - - {() => ( -
- {typingIndicator} -
- )} -
- )} -
-
- - {/* Edge fades: obvious "more pills off view" affordance. Rendered only + )} +
+
+
+ {/* 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. They sit at the full-bleed viewport's edges — the container's visual edges — and w-12 spans the 20px gutter plus a soft zone over the pills scrolling under it. */} - {fades.start ? ( -
- ) : null} - {fades.end ? ( -
- ) : null} -
+ {fades.start ? ( +
+ ) : null} + {fades.end ? ( +
+ ) : null} +
+ ); } diff --git a/desktop/src/features/channels/ui/composerStripAvatarLayout.ts b/desktop/src/features/channels/ui/composerStripAvatarLayout.ts new file mode 100644 index 0000000000..d403373f48 --- /dev/null +++ b/desktop/src/features/channels/ui/composerStripAvatarLayout.ts @@ -0,0 +1,61 @@ +import * as React from "react"; + +/** + * Per-strip namespace for the composer strip's avatar shared-element ids. + * + * The same agent avatar has two possible homes inside a strip — the merged + * typing group and a status pill — and a partition flip (typing agent + * promoted to a pill) should MORPH the avatar between them via a matching + * motion `layoutId` instead of exiting one and entering the other. + * + * layoutIds are app-global, and the channel composer strip and the thread + * panel strip can be mounted at once, so the ids must be namespaced per + * strip instance — two simultaneously mounted elements sharing a layoutId + * would fight over the "lead" and a morph could travel between strips. + * Motion's own namespacing tool (LayoutGroup id) is deliberately NOT used: + * LayoutGroup re-measures every layout-animating member whenever any member + * updates, which turns each pill label ticker resize into a group-wide + * onLayoutAnimationStart storm that permanently holds the pills' deferred + * label swaps. A per-strip scope string baked into the id gives the + * isolation without the group re-measurement semantics. + * + * BotActivityComposerAction provides the scope (its React.useId()); + * the pill and TypingIndicatorRow read it through + * useComposerStripAvatarLayoutId. Outside a strip the context is null and + * avatars render without a layoutId. + */ +export const ComposerStripAvatarScopeContext = React.createContext< + string | null +>(null); + +/** + * Shared-element layout id for an agent's avatar within one strip scope. + * Exposed for tests; components should prefer the hook below. + */ +export function composerStripAvatarLayoutId(scope: string, pubkey: string) { + return `bot-activity-avatar-${scope}-${pubkey.toLowerCase()}`; +} + +/** + * The avatar's shared-element layout id in the enclosing composer strip, or + * undefined when rendered outside a strip (no morph counterpart exists). + */ +export function useComposerStripAvatarLayoutId( + pubkey: string, +): string | undefined { + const scope = React.useContext(ComposerStripAvatarScopeContext); + return scope === null + ? undefined + : composerStripAvatarLayoutId(scope, pubkey); +} + +/** + * Layout spring for the avatar morph, shared by both morph endpoints + * (whichever end MOUNTS drives the transition). Quicker than the strip's + * slot reorder spring — the morph travels the short hop between the + * trailing typing group and the adjacent new pill and should read as one + * quick slide. + */ +export const COMPOSER_STRIP_AVATAR_MORPH_TRANSITION = { + layout: { type: "spring", duration: 0.45, bounce: 0.2 }, +} as const; diff --git a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx index 79e319e308..f3111cf7da 100644 --- a/desktop/src/features/messages/ui/TypingIndicatorRow.tsx +++ b/desktop/src/features/messages/ui/TypingIndicatorRow.tsx @@ -1,5 +1,10 @@ import * as React from "react"; +import { motion, useReducedMotion } from "motion/react"; +import { + COMPOSER_STRIP_AVATAR_MORPH_TRANSITION, + useComposerStripAvatarLayoutId, +} from "@/features/channels/ui/composerStripAvatarLayout"; import { resolveUserLabel, type UserProfileLookup, @@ -52,6 +57,47 @@ function formatTypingLabel(names: string[]) { return `${names[0]}, ${names[1]}, and ${names.length - 2} more are typing...`; } +/** + * One typer's avatar in the overlapping set. Inside a composer activity + * strip, the avatar carries that strip's shared-element layout id (see + * composerStripAvatarLayout) so an agent's avatar MORPHS between the merged + * typing group and its status pill on partition flips instead of exiting + * one and entering the other. Outside a strip (context null) or under + * reduced motion the id is dropped and the avatar mounts in place. + */ +function TypingAvatar({ + avatarUrl, + label, + overlap, + pubkey, +}: { + avatarUrl: string | null; + label: string; + overlap: boolean; + pubkey: string; +}) { + const shouldReduceMotion = useReducedMotion(); + const morphLayoutId = useComposerStripAvatarLayoutId(pubkey); + return ( + + + + ); +} + export function TypingIndicatorRow({ channel, className, @@ -85,27 +131,15 @@ export function TypingIndicatorRow({ {labels.length > 0 && (
- {typingPubkeys.map((pubkey, index) => { - const profile = profiles?.[pubkey.toLowerCase()]; - const label = labels[index] ?? truncatePubkey(pubkey); - return ( -
0 && "-ml-1.5", - )} - data-testid="message-typing-avatar" - > - -
- ); - })} + {typingPubkeys.map((pubkey, index) => ( + 0} + pubkey={pubkey} + /> + ))}