diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx
index a33c6ee7d..038319e2c 100644
--- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx
+++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import type {
ConversationDetailReport,
ConversationFeed,
@@ -268,6 +268,23 @@ function ConversationReplyFooter(props: {
cancelPendingMessagesRef.current = cancelPendingMessages;
const onPinRequestRef = useRef(props.onPinRequest);
onPinRequestRef.current = props.onPinRequest;
+ const pendingMessageVersion = props.pendingMessages
+ .map((message) =>
+ [
+ message.inboundMessageId,
+ message.messageId,
+ message.clientStatus,
+ message.delivery,
+ ].join(":"),
+ )
+ .join("|");
+ const pendingMessageVersionRef = useRef(pendingMessageVersion);
+ useEffect(() => {
+ if (pendingMessageVersionRef.current === pendingMessageVersion) return;
+ pendingMessageVersionRef.current = pendingMessageVersion;
+ if (!window.matchMedia("(max-width: 767px)").matches) return;
+ onPinRequestRef.current();
+ }, [pendingMessageVersion]);
const onSubmit = useCallback(
async (message: string, idempotencyKey: string) => {
await appendMessageRef.current.mutateAsync({
@@ -316,6 +333,11 @@ function ConversationReplyFooter(props: {
),
);
+ const onMailboxLayoutChange = useCallback(() => {
+ if (!window.matchMedia("(max-width: 767px)").matches) return;
+ onPinRequestRef.current();
+ }, []);
+
return (
{/* Queue chrome may scroll; keep the composer pinned below it on mobile. */}
@@ -339,6 +361,7 @@ function ConversationReplyFooter(props: {
conversation={props.conversation}
messages={props.pendingMessages}
onCancelMessage={onCancelMessage}
+ onLayoutChange={onMailboxLayoutChange}
onRetry={onRetry}
/>
diff --git a/packages/junior-dashboard/src/client/conversations/PendingMailboxStack.tsx b/packages/junior-dashboard/src/client/conversations/PendingMailboxStack.tsx
index 7c24e854e..6f3fd2ef6 100644
--- a/packages/junior-dashboard/src/client/conversations/PendingMailboxStack.tsx
+++ b/packages/junior-dashboard/src/client/conversations/PendingMailboxStack.tsx
@@ -1,4 +1,10 @@
-import { useState, type ReactElement, type ReactNode } from "react";
+import {
+ useLayoutEffect,
+ useRef,
+ useState,
+ type ReactElement,
+ type ReactNode,
+} from "react";
import {
AlertCircle,
Clock3,
@@ -241,9 +247,23 @@ export function PendingMailboxStack(props: {
conversation: ConversationTranscript;
messages: readonly ConversationMailboxMessage[];
onCancelMessage?: (message: ConversationMailboxMessage) => void;
+ /** Fires after expand/collapse changes the stack height above the composer. */
+ onLayoutChange?: () => void;
onRetry?(message: ConversationMailboxMessage): void;
}): ReactNode {
const [expanded, setExpanded] = useState(false);
+ const onLayoutChangeRef = useRef(props.onLayoutChange);
+ onLayoutChangeRef.current = props.onLayoutChange;
+ const hasMountedExpandedRef = useRef(false);
+ // Expand/collapse changes footer height. Pin after layout so the transcript
+ // scroll root already has the new client height.
+ useLayoutEffect(() => {
+ if (!hasMountedExpandedRef.current) {
+ hasMountedExpandedRef.current = true;
+ return;
+ }
+ onLayoutChangeRef.current?.();
+ }, [expanded]);
const unresolvedIds = new Set(
unresolvedPendingTranscriptMessages(
conversationTranscriptMessages(props.conversation),
diff --git a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts
index 596741b4c..a06282673 100644
--- a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts
+++ b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts
@@ -13,6 +13,7 @@ import type { ConversationTranscript, TranscriptViewPart } from "../types";
import { conversationTranscriptMessages } from "./eventTranscript";
const BOTTOM_PROXIMITY_PX = 96;
+const MOBILE_MEDIA_QUERY = "(max-width: 767px)";
const USER_SCROLL_DELTA_PX = 2;
type ScrollRoot = HTMLElement | Window;
@@ -166,6 +167,7 @@ export function usePinnedTranscriptBottom(input: {
const previousScrollTopRef = useRef(null);
const prependSnapshotRef = useRef(null);
const pinRequestVersionRef = useRef(input.pinRequestVersion ?? 0);
+ const versionRef = useRef(input.version);
const programmaticScrollGenerationRef = useRef(0);
const [following, setFollowing] = useState(false);
const [hasPendingUpdate, setHasPendingUpdate] = useState(false);
@@ -338,6 +340,23 @@ export function usePinnedTranscriptBottom(input: {
measurePosition("measure");
}, [measurePosition, scrollToBottom]);
+ // Mobile product contract: while live, new tail content always follows.
+ // Still require live mode so a completed/status-only version flip does not jump.
+ useBrowserLayoutEffect(() => {
+ if (versionRef.current === input.version) return;
+ versionRef.current = input.version;
+ if (
+ !input.enabled ||
+ typeof window === "undefined" ||
+ !window.matchMedia(MOBILE_MEDIA_QUERY).matches
+ ) {
+ return;
+ }
+ setFollowingIntent(true);
+ setHasPendingUpdate(false);
+ scrollToBottom("auto");
+ }, [input.enabled, input.version, scrollToBottom, setFollowingIntent]);
+
useBrowserLayoutEffect(() => {
const wasEnabled = enabledRef.current;
const shouldTrack = input.enabled || wasEnabled;
@@ -398,6 +417,10 @@ export function usePinnedTranscriptBottom(input: {
syncAfterLayoutChange();
});
observer.observe(contentElement);
+ // Footer growth shrinks the transcript scroll root without resizing the
+ // transcript content node. Watch the root so follow mode stays pinned.
+ const root = scrollRootFor(contentElement);
+ if (root && !isWindowRoot(root)) observer.observe(root);
return () => observer.disconnect();
}, [contentElement, syncAfterLayoutChange]);