fix(composer): pin activity row height so composer doesn't shift on agent turns

- 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 <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-05 16:41:55 -07:00
committed by Carl
parent bf67337a66
commit da349763d5
3 changed files with 132 additions and 12 deletions
@@ -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 (
<div
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-2 overflow-visible">
{workingBotPubkeys.length > 0 ? (
<div className="flex min-w-0 flex-1 overflow-visible">
<BotActivityComposerAction
agents={agents}
channelId={channel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
variant="inline"
workingBotPubkeys={workingBotPubkeys}
/>
</div>
) : null}
{typingPubkeys.length > 0 ? (
<TypingIndicatorRow
channel={channel}
className="min-w-0 flex-1 py-0 pl-[calc(0.75rem+1px)] pr-0 sm:pl-[calc(1rem+1px)]"
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
) : null}
</div>
</div>
);
}
@@ -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. */}
<ChannelComposerActivityAccessory
agents={activityAgents}
channel={activeChannel}
currentPubkey={currentPubkey}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
visible={hasComposerBottomActivity}
workingBotPubkeys={composerWorkingBotPubkeys}
/>
<ComposerActivityAccessory visible={hasComposerBottomActivity}>
<ChannelComposerActivityRow
agents={activityAgents}
channel={activeChannel}
currentPubkey={currentPubkey}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
</ComposerActivityAccessory>
</div>
</div>
)}
+45
View File
@@ -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,
}) => {