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}
+ />
+ ))}