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,
}) => {