diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index 196aabbec4..bb7276b12f 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -33,6 +33,10 @@ const PREVIEW_CARD = 70; const MESSAGE_ITEM_BOTTOM_PADDING = 10; // TimelineMessageList pb-2.5 const MIN_ESTIMATE = 60; // never reserve less than the old flat floor const CONTINUATION_MIN_ESTIMATE = 28; +// CollapsibleMessageBody clamps long bodies; over-reserving the full dump +// makes the virtualizer leave huge empty gaps until paint. +const COLLAPSED_BODY_MAX_PX = 240; +const EXPAND_TOGGLE_ROW = 22; function mediaHeightFromDim(dim: string | undefined): number { const dimensions = dimensionsFromDim(dim); @@ -117,10 +121,10 @@ export function estimateRowHeight( const proseForLineCount = stripMediaOnlyLines(prose); let height = isContinuation ? CONTINUATION_ROW_CHROME : ROW_CHROME; - height += + let bodyHeight = wrappedLineCount(proseForLineCount.trim() === "" ? "" : proseForLineCount) * TEXT_LINE_HEIGHT; - height += codeLines * CODE_LINE_HEIGHT; + bodyHeight += codeLines * CODE_LINE_HEIGHT; const imetaUrls = new Set(); if (message.tags && message.tags.length > 0) { @@ -128,13 +132,13 @@ export function estimateRowHeight( for (const entry of imeta.values()) { if (!entry.url) continue; imetaUrls.add(entry.url); - height += mediaReserveHeight(entry.dim); + bodyHeight += mediaReserveHeight(entry.dim); } } for (const url of mediaUrlsInBody(body)) { if (imetaUrls.has(url)) continue; // already counted via its imeta dim // dim-less inline media reserves the fixed markdown image box plus its mt-1. - height += mediaReserveHeight(undefined); + bodyHeight += mediaReserveHeight(undefined); } // A bare non-media URL on its own line usually renders a link-preview card. @@ -144,7 +148,13 @@ export function estimateRowHeight( (line) => /^\s*https?:\/\/\S+\s*$/.test(line) && !MEDIA_URL_RE.test(line.trim()), ); - if (hasPreviewUrlLine) height += PREVIEW_CARD; + if (hasPreviewUrlLine) bodyHeight += PREVIEW_CARD; + + if (bodyHeight > COLLAPSED_BODY_MAX_PX) { + height += COLLAPSED_BODY_MAX_PX + EXPAND_TOGGLE_ROW; + } else { + height += bodyHeight; + } if (message.reactions && message.reactions.length > 0) height += REACTION_ROW; diff --git a/desktop/src/features/messages/ui/CollapsibleMessageBody.tsx b/desktop/src/features/messages/ui/CollapsibleMessageBody.tsx new file mode 100644 index 0000000000..ff026e9ff2 --- /dev/null +++ b/desktop/src/features/messages/ui/CollapsibleMessageBody.tsx @@ -0,0 +1,91 @@ +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { + COLLAPSED_MESSAGE_MAX_HEIGHT_PX, + messageBodyNeedsClamp, + shouldForceExpandMessageBody, +} from "./collapsibleMessageBody"; + +type CollapsibleMessageBodyProps = { + children: React.ReactNode; + /** Route-target highlight — keep the body fully visible. */ + highlighted?: boolean; + /** Active timeline search — expand so matches aren't behind the fold. */ + searchQuery?: string; + className?: string; +}; + +/** + * Clamps tall message bodies (agent dumps, long pastes) behind Show more / + * Show less. Expansion is local to the mounted row and is not persisted. + */ +export function CollapsibleMessageBody({ + children, + highlighted = false, + searchQuery, + className, +}: CollapsibleMessageBodyProps) { + const contentRef = React.useRef(null); + const [needsClamp, setNeedsClamp] = React.useState(false); + const [expanded, setExpanded] = React.useState(false); + + const forceExpand = shouldForceExpandMessageBody({ + highlighted, + searchQuery, + }); + const isExpanded = forceExpand || expanded; + + React.useLayoutEffect(() => { + const el = contentRef.current; + if (!el) return; + + const measure = () => { + // scrollHeight is the full content height even under max-height. + setNeedsClamp(messageBodyNeedsClamp(el.scrollHeight)); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const showToggle = needsClamp && !forceExpand; + + return ( +
+
+
+ {children} +
+ {!isExpanded && needsClamp ? ( +
+ ) : null} +
+ {showToggle ? ( + + ) : null} +
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 9f55e712f1..c11dd2b025 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -40,6 +40,7 @@ import { resolveSnapshotSharedBy } from "@/features/messages/lib/snapshotSharedB import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; +import { CollapsibleMessageBody } from "./CollapsibleMessageBody"; import { MessageActionBar } from "./MessageActionBar"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; @@ -356,31 +357,36 @@ export const MessageRow = React.memo( } return ( - + > + + ); } }; diff --git a/desktop/src/features/messages/ui/collapsibleMessageBody.test.mjs b/desktop/src/features/messages/ui/collapsibleMessageBody.test.mjs new file mode 100644 index 0000000000..6e3231fcc3 --- /dev/null +++ b/desktop/src/features/messages/ui/collapsibleMessageBody.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + COLLAPSED_MESSAGE_MAX_HEIGHT_PX, + messageBodyNeedsClamp, + shouldForceExpandMessageBody, +} from "./collapsibleMessageBody.ts"; + +describe("collapsibleMessageBody", () => { + it("clamps only when content exceeds the max by more than 1px", () => { + assert.equal(messageBodyNeedsClamp(COLLAPSED_MESSAGE_MAX_HEIGHT_PX), false); + assert.equal( + messageBodyNeedsClamp(COLLAPSED_MESSAGE_MAX_HEIGHT_PX + 1), + false, + ); + assert.equal( + messageBodyNeedsClamp(COLLAPSED_MESSAGE_MAX_HEIGHT_PX + 2), + true, + ); + }); + + it("force-expands for route highlights and non-empty search", () => { + assert.equal(shouldForceExpandMessageBody({ highlighted: true }), true); + assert.equal( + shouldForceExpandMessageBody({ searchQuery: " checkout " }), + true, + ); + assert.equal(shouldForceExpandMessageBody({ searchQuery: " " }), false); + assert.equal(shouldForceExpandMessageBody({}), false); + }); +}); diff --git a/desktop/src/features/messages/ui/collapsibleMessageBody.ts b/desktop/src/features/messages/ui/collapsibleMessageBody.ts new file mode 100644 index 0000000000..79cd0fe7ad --- /dev/null +++ b/desktop/src/features/messages/ui/collapsibleMessageBody.ts @@ -0,0 +1,21 @@ +/** ~12 lines of text-sm at typical line-height — matches Slack-ish clamp. */ +export const COLLAPSED_MESSAGE_MAX_HEIGHT_PX = 240; + +/** Expand when the row is a search/route target so the match isn't hidden. */ +export function shouldForceExpandMessageBody({ + highlighted, + searchQuery, +}: { + highlighted?: boolean; + searchQuery?: string; +}): boolean { + if (highlighted) return true; + return Boolean(searchQuery?.trim()); +} + +export function messageBodyNeedsClamp( + scrollHeight: number, + maxHeight = COLLAPSED_MESSAGE_MAX_HEIGHT_PX, +): boolean { + return scrollHeight > maxHeight + 1; +}