fix(app): paint a long user message collapsed from its first frame (#9302)

MessagePreview learned it overflowed from onLayout, which arrives after
the mount has painted on web and Fabric. Every mount (opening the chat,
a prompt arriving, a row the list mounts again) painted the text hard-cut
with no fade or Show more, then grew the row by the control a frame later.
The expanded choice also lived in the row and reset with each remount.

Measure the body in a layout effect so the first commit carries the
control, and keep the expanded choice in a per-agent store the timeline
provides, as tool calls already do.
This commit is contained in:
Mohamed Boudra
2026-11-11 08:25:55 +07:00
committed by GitHub
parent de1b85ecfb
commit 603ee5ced1
10 changed files with 398 additions and 49 deletions
@@ -8,6 +8,8 @@ import { ExpandableBadge } from "@/components/message";
import { page, userEvent } from "vitest/browser";
import { PlanCard } from "@/components/plan-card";
import { MessagePreview } from "@/components/user-message";
import { createUserMessageExpansion } from "@/components/user-message/expansion";
import { UserMessageExpansionContext } from "@/components/user-message/expansion-context";
import { DisclosureRow } from "./disclosure";
import type { DisclosureTriggerEdge, StreamViewportHandle } from "./strategy";
@@ -74,26 +76,19 @@ const view = () => within(container);
const preLine = { whiteSpace: "pre-line" } as const;
async function waitForLayout() {
await act(async () => {
for (let frame = 0; frame < 2; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
});
}
describe("timeline disclosures hold their row", () => {
it("routes user message show more and show less through the row's hold", async () => {
it("routes user message show more and show less through the row's hold", () => {
const longText = Array.from({ length: 60 }, (_, line) => `Line ${line + 1}`).join("\n");
const expansion = createUserMessageExpansion();
const holds = mountRow(
"message-1",
() => view().queryByRole("button", { name: "Show less" }) !== null,
<MessagePreview>
<span style={preLine}>{longText}</span>
</MessagePreview>,
<UserMessageExpansionContext.Provider value={expansion}>
<MessagePreview itemId="message-1">
<span style={preLine}>{longText}</span>
</MessagePreview>
</UserMessageExpansionContext.Provider>,
);
// The preview learns it overflows from its first layout.
await waitForLayout();
act(() => view().getByRole("button", { name: "Show more" }).click());
expect(view().getByRole("button", { name: "Show less" })).toBeTruthy();
+34 -26
View File
@@ -66,6 +66,10 @@ import {
useAgentToolCallExpansion,
useExpandedToolCallGroups,
} from "@/tool-calls/expansion-context";
import {
UserMessageExpansionContext,
useAgentUserMessageExpansion,
} from "@/components/user-message/expansion-context";
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
import { createStreamPresentation } from "./presentation";
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
@@ -382,6 +386,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
new Set(),
);
const toolCallExpansion = useAgentToolCallExpansion(agentId);
const userMessageExpansion = useAgentUserMessageExpansion(agentId);
const expandedToolCallGroups = useExpandedToolCallGroups(toolCallExpansion);
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
@@ -711,6 +716,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
return (
<UserMessage
itemId={item.id}
serverId={resolvedServerId}
agentId={agentId}
messageId={item.messageId}
@@ -1169,32 +1175,34 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<AssistantSelectionCopySurface style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
<ToolCallExpansionContext.Provider value={toolCallExpansion}>
{streamRenderStrategy.render({
readingRowReplacements: presentation.readingRowReplacements,
agentId,
segments: renderModel.segments,
historyRowRevision,
liveHeadRowRevision: expandedToolCallGroups,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange: nearBottom.set,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
olderHistoryProgressKey: progressKey,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
contentMaxWidth,
})}
<UserMessageExpansionContext.Provider value={userMessageExpansion}>
{streamRenderStrategy.render({
readingRowReplacements: presentation.readingRowReplacements,
agentId,
segments: renderModel.segments,
historyRowRevision,
liveHeadRowRevision: expandedToolCallGroups,
boundary,
renderers,
listEmptyComponent,
viewportRef,
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
isInitialHistoryLoading,
revealInitialHistory,
onNearBottomChange: nearBottom.set,
onReadingPositionChange: handleReadingPositionChange,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
olderHistoryProgressKey: progressKey,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
contentMaxWidth,
})}
</UserMessageExpansionContext.Provider>
</ToolCallExpansionContext.Provider>
</MessageOuterSpacingProvider>
<ChatOutlineRail
+4 -1
View File
@@ -92,6 +92,8 @@ export type { InlinePathTarget } from "@/assistant-file-links";
export type { AssistantForkTarget };
interface UserMessageProps {
/** The timeline item's id, which keeps the message's expanded state. */
itemId: string;
sender?: AgentMessage["sender"];
serverId?: string;
agentId?: string;
@@ -391,6 +393,7 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
const MESSAGE_TEXT_DATASET = { messageText: "true" };
export const UserMessage = memo(function UserMessage({
itemId,
sender,
serverId,
agentId,
@@ -480,7 +483,7 @@ export const UserMessage = memo(function UserMessage({
>
{sender ? <MessageSender sender={sender} serverId={serverId} /> : null}
<View style={userMessageStylesheet.bubble}>
<MessagePreview>
<MessagePreview itemId={itemId}>
{hasImages ? (
<View style={imagePreviewContainerStyle}>
{images.map((image) => (
@@ -0,0 +1,30 @@
import { createContext, useCallback, useContext, useState, useSyncExternalStore } from "react";
import { createUserMessageExpansion, type UserMessageExpansion } from "./expansion";
export const UserMessageExpansionContext = createContext<UserMessageExpansion | null>(null);
/** One store per viewed agent; switching agents starts with every message collapsed. */
export function useAgentUserMessageExpansion(agentId: string): UserMessageExpansion {
const [owner, setOwner] = useState(() => ({
agentId,
expansion: createUserMessageExpansion(),
}));
if (owner.agentId === agentId) return owner.expansion;
const next = { agentId, expansion: createUserMessageExpansion() };
setOwner(next);
return next.expansion;
}
/** Subscribes one message to its own expanded state. */
export function useUserMessageExpanded(itemId: string) {
const expansion = useContext(UserMessageExpansionContext);
if (!expansion) throw new Error("MessagePreview requires a UserMessageExpansionContext");
const subscribe = useCallback(
(listener: () => void) => expansion.subscribe(itemId, listener),
[expansion, itemId],
);
const getSnapshot = useCallback(() => expansion.isExpanded(itemId), [expansion, itemId]);
const expanded = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const toggle = useCallback(() => expansion.toggle(itemId), [expansion, itemId]);
return { expanded, toggle };
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { createUserMessageExpansion } from "./expansion";
describe("user message expansion", () => {
it("toggles one message and notifies only that message", () => {
const expansion = createUserMessageExpansion();
const notified: string[] = [];
expansion.subscribe("first", () => notified.push("first"));
expansion.subscribe("second", () => notified.push("second"));
expansion.toggle("first");
expect(expansion.isExpanded("first")).toBe(true);
expect(expansion.isExpanded("second")).toBe(false);
expansion.toggle("first");
expect(expansion.isExpanded("first")).toBe(false);
expect(notified).toEqual(["first", "first"]);
});
it("keeps a message's choice after its subscribers leave, as a remounting row does", () => {
const expansion = createUserMessageExpansion();
const unsubscribe = expansion.subscribe("first", () => undefined);
expansion.toggle("first");
unsubscribe();
const notified: string[] = [];
expansion.subscribe("first", () => notified.push("first"));
expect(expansion.isExpanded("first")).toBe(true);
expect(notified).toEqual([]);
});
});
@@ -0,0 +1,34 @@
type Listener = () => void;
/**
* Which of one agent's long user messages the user expanded past the preview. It lives with the
* timeline, not the row, so a row that remounts (a virtualized row coming back into view, a row
* reassigned to another list container) paints the choice the user made. A toggle notifies only
* that message's subscribers.
*/
export function createUserMessageExpansion() {
const expanded = new Set<string>();
const listeners = new Map<string, Set<Listener>>();
return {
isExpanded: (itemId: string) => expanded.has(itemId),
subscribe(itemId: string, listener: Listener) {
let itemListeners = listeners.get(itemId);
if (!itemListeners) {
itemListeners = new Set();
listeners.set(itemId, itemListeners);
}
itemListeners.add(listener);
return () => {
itemListeners.delete(listener);
if (itemListeners.size === 0) listeners.delete(itemId);
};
},
toggle(itemId: string) {
if (expanded.has(itemId)) expanded.delete(itemId);
else expanded.add(itemId);
for (const listener of listeners.get(itemId) ?? []) listener();
},
};
}
export type UserMessageExpansion = ReturnType<typeof createUserMessageExpansion>;
@@ -0,0 +1,81 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { within } from "@testing-library/dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n as testI18n } from "@/i18n/i18next";
import { MessagePreview } from "./index";
import { createUserMessageExpansion, type UserMessageExpansion } from "./expansion";
import { UserMessageExpansionContext } from "./expansion-context";
// Load translations so the toggle exposes its real accessible name.
void testI18n;
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
container.style.width = "400px";
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const longText = Array.from({ length: 60 }, (_, line) => `Line ${line + 1}`).join("\n");
const preLine = { whiteSpace: "pre-line" } as const;
const view = () => within(container);
function renderMessage(expansion: UserMessageExpansion) {
act(() =>
root.render(
<UserMessageExpansionContext.Provider value={expansion}>
<MessagePreview itemId="message-1">
<span style={preLine}>{longText}</span>
</MessagePreview>
</UserMessageExpansionContext.Provider>,
),
);
}
async function waitForLayout() {
await act(async () => {
for (let frame = 0; frame < 2; frame += 1) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
});
}
describe("long user message preview", () => {
it("commits the control with the mount, so the first frame is the settled one", async () => {
renderMessage(createUserMessageExpansion());
// Nothing has painted yet: whatever is in the DOM now is the first frame.
expect(view().getByRole("button", { name: "Show more" })).toBeTruthy();
const firstFrameHeight = container.getBoundingClientRect().height;
await waitForLayout();
expect(view().getByRole("button", { name: "Show more" })).toBeTruthy();
expect(container.getBoundingClientRect().height).toBe(firstFrameHeight);
});
it("paints the user's expanded choice when the row remounts", async () => {
const expansion = createUserMessageExpansion();
renderMessage(expansion);
act(() => view().getByRole("button", { name: "Show more" }).click());
await waitForLayout();
const expandedHeight = container.getBoundingClientRect().height;
act(() => root.render(null));
renderMessage(expansion);
expect(view().getByRole("button", { name: "Show less" })).toBeTruthy();
expect(container.getBoundingClientRect().height).toBe(expandedHeight);
});
});
@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo, type ReactNode } from "react";
import { useState, useCallback, useLayoutEffect, useMemo, useRef, type ReactNode } from "react";
import { View, type LayoutChangeEvent, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
@@ -10,6 +10,7 @@ import { useSessionStore } from "@/stores/session-store";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { isNative } from "@/constants/platform";
import { useDisclosureHold } from "@/agent-stream/disclosure";
import { useUserMessageExpanded } from "./expansion-context";
const PREVIEW_HEIGHT = 240;
// Show more and show less sit under the text they open and close.
@@ -21,24 +22,35 @@ const previewStyle: ViewStyle = {
overflow: isNative ? "scroll" : "hidden",
};
export function MessagePreview({ children }: { children: ReactNode }) {
/** Caps a long message at the preview height, with a control to read the rest. */
export function MessagePreview({ itemId, children }: { itemId: string; children: ReactNode }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const { expanded, toggle: toggleExpanded } = useUserMessageExpanded(itemId);
const bodyRef = useRef<View>(null);
const [overflowing, setOverflowing] = useState(false);
// A hidden panel lays out at zero height, which says nothing about the message.
const applyBodyHeight = useCallback((height: number) => {
if (height > 0) setOverflowing(height > PREVIEW_HEIGHT);
}, []);
// Measure in the commit that mounts the body: an update from a layout effect paints with it, so
// the first frame already shows the fade and the control. `onLayout` only lands after paint.
useLayoutEffect(() => {
applyBodyHeight(bodyRef.current!.getBoundingClientRect().height);
}, [applyBodyHeight]);
const measure = useCallback(
(event: LayoutChangeEvent) => setOverflowing(event.nativeEvent.layout.height > PREVIEW_HEIGHT),
[],
(event: LayoutChangeEvent) => applyBodyHeight(event.nativeEvent.layout.height),
[applyBodyHeight],
);
const hold = useDisclosureHold();
const toggle = useCallback(
() => hold(() => setExpanded((value) => !value), TRIGGER_BELOW_CONTENT),
[hold],
() => hold(toggleExpanded, TRIGGER_BELOW_CONTENT),
[hold, toggleExpanded],
);
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
return (
<>
<View style={!expanded && previewStyle}>
<View style={styles.body} onLayout={measure}>
<View ref={bodyRef} style={styles.body} onLayout={measure}>
{children}
</View>
{overflowing && !expanded ? (