diff --git a/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx b/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx new file mode 100644 index 000000000..a82170f01 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx @@ -0,0 +1,74 @@ +import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal"; +import { + BotActivityComposerAction, + type BotActivityAgent, +} from "@/features/channels/ui/BotActivityBar"; +import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { Channel } from "@/shared/api/types"; + +/** + * Status strip anchored directly below the message composer: the inline + * "agents working" trigger plus the typing indicator. + * + * The row has a FIXED height (not min-h): it must not grow when the inline + * bot-activity button (h-7) mounts, or the bottom-anchored composer above it + * visibly bumps up. 34px (h-8.5) = 28px button + 6px bottom padding, the + * row's rendered height while a trigger is present. Guarded by the "composer + * does not shift when the activity row mounts and clears" e2e test. + */ +export function ChannelComposerActivityRow({ + agents, + channel, + currentPubkey, + onOpenAgentSession, + openAgentSessionPubkey, + profiles, + typingPubkeys, +}: { + agents: BotActivityAgent[]; + channel: Channel | null; + currentPubkey?: string; + onOpenAgentSession: (pubkey: string, channelId?: string | null) => void; + openAgentSessionPubkey: string | null; + profiles?: UserProfileLookup; + typingPubkeys: string[]; +}) { + // Unified working set for the composer bar: observer-derived turns primary, + // bot typing fallback (both folded together by agentWorkingSignal). This is + // what makes the bar show for an agent whose observer stream is live but + // whose typing signal never arrives — and vice versa. + const workingBotPubkeys = useChannelWorkingAgentPubkeys(channel?.id ?? null); + + return ( +
+
+ {workingBotPubkeys.length > 0 ? ( +
+ +
+ ) : null} + {typingPubkeys.length > 0 ? ( + + ) : null} +
+
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 9e5152edf..96f32a340 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -39,7 +39,8 @@ import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPre import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal"; import { useCardMintJobs } from "@/features/agents/cardMintStore"; import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; -import { ChannelComposerActivityAccessory } from "@/features/channels/ui/ChannelComposerActivityAccessory"; +import { ChannelComposerActivityRow } from "@/features/channels/ui/ChannelComposerActivityRow"; +import { ComposerActivityAccessory } from "@/features/messages/ui/ComposerActivityAccessory"; import { containsWelcomePersonaMention, WelcomeComposerBanner, @@ -797,17 +798,17 @@ export const ChannelPane = React.memo(function ChannelPane({ bottom rail, so fading it cannot change the observed overlay height or move the conversation. Its natural content height remains responsive. */} - + + + )} diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index ac9d5318e..404ccb9e8 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2066,6 +2066,51 @@ test("shows and clears activity indicators for active channel agents", async ({ ); }); +test("composer does not shift when the activity row mounts and clears", async ({ + page, +}) => { + await page.goto("/"); + + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + await waitForMockLiveSubscription(page, "agents", KIND_TYPING_INDICATOR); + + // The activity row below the composer has a fixed height (h-8.5 in + // ChannelComposerActivityRow) so the bottom-anchored composer must not move + // when the bot-activity trigger mounts into it or clears from it. + const composerBox = async () => { + const box = await page.getByTestId("message-composer").boundingBox(); + if (!box) { + throw new Error("Composer is not visible."); + } + return box; + }; + const idleComposerTop = (await composerBox()).y; + + await page.evaluate((pubkey) => { + window.__BUZZ_E2E_EMIT_MOCK_TYPING__?.({ + channelName: "agents", + pubkey, + }); + }, TEST_IDENTITIES.alice.pubkey); + + await expect(page.getByTestId("bot-activity-composer-trigger")).toBeVisible(); + expect((await composerBox()).y).toBeCloseTo(idleComposerTop, 0); + + await page.evaluate((pubkey) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "agents", + content: "Done.", + pubkey, + }); + }, TEST_IDENTITIES.alice.pubkey); + + await expect(page.getByTestId("bot-activity-composer-trigger")).toHaveCount( + 0, + ); + expect((await composerBox()).y).toBeCloseTo(idleComposerTop, 0); +}); + test("members sidebar exposes view-activity for a viewer-owned relay agent", async ({ page, }) => {