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]);