From da349763d5d4992e8b3eb1a6774249eb326ca896 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 26 Jul 2026 20:32:15 -0700 Subject: [PATCH] fix(composer): pin activity row height so composer doesn't shift on agent turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract the composer status strip into ChannelComposerActivityRow.tsx (new component hosting the inline bot-activity trigger and the typing indicator, plus the useChannelWorkingAgentPubkeys subscription). Also brings ChannelPane.tsx back under the 1000-line file-size ceiling (1006 -> 969). - Change the row from min-h-8 to a fixed h-8.5 (34px). The inline bot-activity trigger is h-7 (28px), which plus the row's pb-1.5 (6px) made the active row 34px — 2px taller than the 32px idle minimum — so the bottom-anchored composer visibly bumped up whenever an agent turn or bot typing mounted the trigger, and dropped back when it cleared. 34px matches the previous active-state height exactly: the working state renders identically and the idle state now reserves the same space. (Typing indicators alone are 20px of content, under the old minimum, which is why only the agent-activity trigger caused the shift.) - channels.spec.ts: add "composer does not shift when the activity row mounts and clears" e2e test asserting the composer's y-position stays stable across trigger mount and clear. Verified via negative control: with min-h-8 restored the test fails with exactly the 2px delta. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../ui/ChannelComposerActivityRow.tsx | 74 +++++++++++++++++++ .../src/features/channels/ui/ChannelPane.tsx | 25 ++++--- desktop/tests/e2e/channels.spec.ts | 45 +++++++++++ 3 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 desktop/src/features/channels/ui/ChannelComposerActivityRow.tsx 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, }) => {