Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type {
ConversationDetailReport,
ConversationFeed,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -316,6 +333,11 @@ function ConversationReplyFooter(props: {
),
);

const onMailboxLayoutChange = useCallback(() => {
if (!window.matchMedia("(max-width: 767px)").matches) return;
onPinRequestRef.current();
}, []);

return (
<div className="flex w-full min-h-0 max-h-[min(55dvh,24rem)] flex-col overflow-hidden self-end px-2 pt-1.5 pb-[calc(0.375rem+env(safe-area-inset-bottom))] md:max-h-none md:overflow-visible md:self-auto md:px-7 md:py-4 md:pb-4">
{/* Queue chrome may scroll; keep the composer pinned below it on mobile. */}
Expand All @@ -339,6 +361,7 @@ function ConversationReplyFooter(props: {
conversation={props.conversation}
messages={props.pendingMessages}
onCancelMessage={onCancelMessage}
onLayoutChange={onMailboxLayoutChange}
onRetry={onRetry}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -166,6 +167,7 @@ export function usePinnedTranscriptBottom(input: {
const previousScrollTopRef = useRef<number | null>(null);
const prependSnapshotRef = useRef<PrependSnapshot | null>(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);
Expand Down Expand Up @@ -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;
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -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]);

Expand Down
Loading