mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 02:46:29 +00:00
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:
@@ -39,6 +39,19 @@ The trigger can only stay put when the list has scroll room for the change. When
|
||||
content fits the viewport, or a collapse at the end would need an offset past the end, the list
|
||||
ends at its end and the trigger moves by the difference.
|
||||
|
||||
## State that survives a remount
|
||||
|
||||
The list mounts a row again whenever it opens the chat, brings a web row back into the history
|
||||
window, or reassigns a native row's LegendList container. State held in the row resets each time.
|
||||
Keep a disclosure's open state in the per-agent store the timeline provides, keyed by item id:
|
||||
`ToolCallExpansionContext` for tool calls, `UserMessageExpansionContext` for long user messages.
|
||||
|
||||
Decide a row's first painted shape in the commit that mounts it. `MessagePreview` measures its body
|
||||
in a layout effect, which commits before paint on web and Fabric. `onLayout` lands after the first
|
||||
frame: a long message measured there paints capped with no fade or show more, then grows by the
|
||||
control's height a frame later. `onLayout` still tracks later size changes. A zero height comes from
|
||||
a hidden panel and is ignored.
|
||||
|
||||
## Web
|
||||
|
||||
`holdRow` pins the row's trigger edge in the reading anchor and runs `apply`; the next reading
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expectAgentIdle } from "../support/helpers/agent-stream";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// A long user message paints capped, with its fade and Show more, in the frame it mounts in. These
|
||||
// tests sample every painted frame from before the chat opens and require each long message to
|
||||
// show one state, the settled one, for its whole life on screen.
|
||||
|
||||
interface PreviewFrame {
|
||||
height: number;
|
||||
fade: boolean;
|
||||
control: string | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__previewFrames?: Record<string, PreviewFrame[]>;
|
||||
}
|
||||
}
|
||||
|
||||
function longPrompt(name: string) {
|
||||
return Array.from({ length: 40 }, (_, line) => `${name} line ${line + 1}`).join("\n");
|
||||
}
|
||||
|
||||
async function recordPreviewFrames(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const frames: Record<string, PreviewFrame[]> = {};
|
||||
window.__previewFrames = frames;
|
||||
const sample = () => {
|
||||
for (const row of document.querySelectorAll('[data-testid="user-message"]')) {
|
||||
const rect = row.getBoundingClientRect();
|
||||
if (rect.height === 0) continue;
|
||||
const text = row.querySelector('[data-message-text="true"]');
|
||||
if (!text) continue;
|
||||
const name = (text.textContent ?? "").split(" line ")[0];
|
||||
// The preview is the ancestor that carries the height cap; the fade paints inside it.
|
||||
let preview = text.parentElement;
|
||||
while (preview && preview !== row && getComputedStyle(preview).maxHeight === "none") {
|
||||
preview = preview.parentElement;
|
||||
}
|
||||
const control = [...row.querySelectorAll("*")].find(
|
||||
(node) => node.children.length === 0 && /^Show (more|less)$/.test(node.textContent ?? ""),
|
||||
);
|
||||
const frame = {
|
||||
height: Math.round(rect.height),
|
||||
fade: preview !== null && preview !== row && preview.querySelector("svg") !== null,
|
||||
control: control?.textContent ?? null,
|
||||
};
|
||||
const seen = (frames[name] ??= []);
|
||||
if (JSON.stringify(seen.at(-1)) !== JSON.stringify(frame)) seen.push(frame);
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectPaintedCollapsedFromFirstFrame(page: Page, name: string) {
|
||||
const message = page.getByTestId("user-message").filter({ hasText: `${name} line 1` });
|
||||
await expect(message.getByRole("button", { name: "Show more" })).toBeVisible();
|
||||
// Let any late measurement land before reading the frames.
|
||||
await page.waitForTimeout(500);
|
||||
const frames = await page.evaluate((key) => window.__previewFrames?.[key] ?? [], name);
|
||||
expect(frames).toEqual([{ height: expect.any(Number), fade: true, control: "Show more" }]);
|
||||
}
|
||||
|
||||
test.describe("long user message preview", () => {
|
||||
test("opening a chat paints its long message collapsed from the first frame", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "user-message-open-",
|
||||
title: "Preview open",
|
||||
initialPrompt: longPrompt("Opened prompt"),
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await recordPreviewFrames(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectPaintedCollapsedFromFirstFrame(page, "Opened prompt");
|
||||
|
||||
await page.reload();
|
||||
await expectPaintedCollapsedFromFirstFrame(page, "Opened prompt");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a long message arriving in an open chat paints collapsed from the first frame", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "user-message-arrive-",
|
||||
title: "Preview arrive",
|
||||
initialPrompt: "short",
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await recordPreviewFrames(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectAgentIdle(page);
|
||||
|
||||
await agent.client.sendAgentMessage(agent.agentId, longPrompt("Arrived prompt"));
|
||||
await expectPaintedCollapsedFromFirstFrame(page, "Arrived prompt");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a long message that arrived while its tab was hidden paints collapsed on return", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "user-message-hidden-",
|
||||
title: "Preview first",
|
||||
initialPrompt: "short",
|
||||
});
|
||||
try {
|
||||
await agent.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: agent.cwd,
|
||||
workspaceId: agent.workspaceId,
|
||||
title: "Preview second",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await recordPreviewFrames(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectAgentIdle(page);
|
||||
|
||||
await page.getByRole("button", { name: "Preview second", exact: true }).click();
|
||||
await agent.client.sendAgentMessage(agent.agentId, longPrompt("Hidden prompt"));
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await page.getByRole("button", { name: "Preview first", exact: true }).click();
|
||||
await expectPaintedCollapsedFromFirstFrame(page, "Hidden prompt");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user