From e4a0c4f74ee3ecafa88941ee5e2fd1c6b29d8639 Mon Sep 17 00:00:00 2001 From: youngbeom Date: Tue, 14 Jul 2026 10:03:53 +0800 Subject: [PATCH] fix(web): refine long message collapse controls --- .../public/icons/long-message-collapse.svg | 3 ++ web/app/public/icons/long-message-expand.svg | 3 ++ .../LongMessageCollapse.test.tsx | 21 ++++++++++ .../MessageContent/LongMessageCollapse.tsx | 33 +++++++--------- .../MessageContent/MessageContent.css | 7 ++++ .../MessageContent/MessageContent.tsx | 4 +- .../components/ui/Icons/LongMessageIcons.tsx | 39 +++++++++++++++++++ web/app/src/components/ui/Icons/index.ts | 1 + web/app/src/shared/i18n/messages.ts | 2 + 9 files changed, 92 insertions(+), 21 deletions(-) create mode 100644 web/app/public/icons/long-message-collapse.svg create mode 100644 web/app/public/icons/long-message-expand.svg create mode 100644 web/app/src/components/ui/Icons/LongMessageIcons.tsx diff --git a/web/app/public/icons/long-message-collapse.svg b/web/app/public/icons/long-message-collapse.svg new file mode 100644 index 00000000..a4c04dfc --- /dev/null +++ b/web/app/public/icons/long-message-collapse.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/public/icons/long-message-expand.svg b/web/app/public/icons/long-message-expand.svg new file mode 100644 index 00000000..bfcbff9c --- /dev/null +++ b/web/app/public/icons/long-message-expand.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/app/src/components/business/MessageContent/LongMessageCollapse.test.tsx b/web/app/src/components/business/MessageContent/LongMessageCollapse.test.tsx index e77024a5..f850296d 100644 --- a/web/app/src/components/business/MessageContent/LongMessageCollapse.test.tsx +++ b/web/app/src/components/business/MessageContent/LongMessageCollapse.test.tsx @@ -114,4 +114,25 @@ describe("LongMessageCollapse", () => { expect(screen.queryByRole("button", { name: "Expand" })).toBeNull(); expect(document.querySelector(".long-message-collapse")).toBeNull(); }); + + it("keeps collapsed height stable while content grows and expands to the latest height", async () => { + naturalContentHeight = naturalNineLineHeight; + const user = userEvent.setup(); + const { rerender } = render(); + + const content = document.querySelector(".long-message-content"); + expect(content).not.toBeNull(); + expect(content?.style.maxHeight).toBe(`${lineHeight * 8}px`); + + naturalContentHeight = lineHeight * 12; + rerender(); + + expect(screen.getByRole("button", { name: "Expand" })).toBeTruthy(); + expect(content?.style.maxHeight).toBe(`${lineHeight * 8}px`); + + await user.click(screen.getByRole("button", { name: "Expand" })); + + expect(screen.getByRole("button", { name: "Collapse" })).toBeTruthy(); + expect(content?.style.maxHeight).toBe(`${lineHeight * 12}px`); + }); }); diff --git a/web/app/src/components/business/MessageContent/LongMessageCollapse.tsx b/web/app/src/components/business/MessageContent/LongMessageCollapse.tsx index 9d4665c7..68a3da58 100644 --- a/web/app/src/components/business/MessageContent/LongMessageCollapse.tsx +++ b/web/app/src/components/business/MessageContent/LongMessageCollapse.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useId, useRef, useState } from "react"; import { Button } from "@/components/ui"; +import { LongMessageCollapseIcon, LongMessageExpandIcon } from "@/components/ui/Icons"; import { classNames } from "@/shared/lib/classNames"; import type { TranslateFn } from "@/models/conversations"; import { prepareMermaidBlocks, renderMermaidBlocks } from "./mermaid"; @@ -73,6 +74,14 @@ export function LongMessageCollapse({ expanded, html, onExpandedChange, t }: Lon const measure = () => { const nextMetrics = calculateMetrics(element); setMetrics((current) => { + if ( + current?.shouldCollapse && + nextMetrics.shouldCollapse && + !isExpanded && + current.collapseHeight === nextMetrics.collapseHeight + ) { + return current; + } if (nextMetrics.shouldCollapse) { return metricsEqual(current, nextMetrics) ? current : nextMetrics; } @@ -145,6 +154,7 @@ export function LongMessageCollapse({ expanded, html, onExpandedChange, t }: Lon transitionDuration: "0ms", } as const; const toggleLabel = isExpanded ? t("messageLongCollapse") : t("messageLongExpand"); + const ToggleIcon = isExpanded ? LongMessageCollapseIcon : LongMessageExpandIcon; return (
@@ -165,7 +175,8 @@ export function LongMessageCollapse({ expanded, html, onExpandedChange, t }: Lon className="long-message-toggle" onClick={toggleExpanded} > - {toggleLabel} +
@@ -192,29 +203,13 @@ function calculateMetrics(element: HTMLDivElement): LongMessageMetrics { function metricsEqual(current: LongMessageMetrics | null, next: LongMessageMetrics): boolean { return ( current?.collapseHeight === next.collapseHeight && - current.expandedHeight === next.expandedHeight && + Math.abs(current.expandedHeight - next.expandedHeight) <= COLLAPSE_HEIGHT_TOLERANCE_PX && current.shouldCollapse === next.shouldCollapse ); } function measureNaturalExpandedHeight(element: HTMLDivElement): number { - const previousMaxHeight = element.style.maxHeight; - const previousOverflow = element.style.overflow; - const previousPaddingBottom = element.style.paddingBottom; - const previousTransitionDuration = element.style.transitionDuration; - - element.style.maxHeight = "none"; - element.style.overflow = "visible"; - element.style.paddingBottom = "0px"; - element.style.transitionDuration = "0ms"; - const expandedHeight = Math.ceil(element.scrollHeight); - - element.style.maxHeight = previousMaxHeight; - element.style.overflow = previousOverflow; - element.style.paddingBottom = previousPaddingBottom; - element.style.transitionDuration = previousTransitionDuration; - - return expandedHeight; + return Math.ceil(element.scrollHeight); } function hasImageLikeContent(element: HTMLDivElement): boolean { diff --git a/web/app/src/components/business/MessageContent/MessageContent.css b/web/app/src/components/business/MessageContent/MessageContent.css index da3231eb..9dfc6764 100644 --- a/web/app/src/components/business/MessageContent/MessageContent.css +++ b/web/app/src/components/business/MessageContent/MessageContent.css @@ -221,9 +221,16 @@ .long-message-toggle { position: relative; z-index: 4; + color: #2f384c; box-shadow: var(--shadow-soft); } +.long-message-toggle-icon { + width: 24px; + height: 24px; + flex: 0 0 24px; +} + :root[data-theme="dark"] .message-bubble:has(.long-message-collapse.is-collapsed)::after { background: linear-gradient(180deg, rgba(15, 23, 42, 0), rgba(15, 23, 42, 0.96)); } diff --git a/web/app/src/components/business/MessageContent/MessageContent.tsx b/web/app/src/components/business/MessageContent/MessageContent.tsx index 2bd222a8..7386f6bd 100644 --- a/web/app/src/components/business/MessageContent/MessageContent.tsx +++ b/web/app/src/components/business/MessageContent/MessageContent.tsx @@ -42,7 +42,7 @@ export function MessageContent({ ); useEffect(() => { - if (enableLongMessageCollapse && t) { + if (enableLongMessageCollapse) { return undefined; } @@ -62,7 +62,7 @@ export function MessageContent({ return () => { cancelled = true; }; - }, [enableLongMessageCollapse, markup, t]); + }, [enableLongMessageCollapse, markup]); if (blankTurnPlaceholder) { return ( diff --git a/web/app/src/components/ui/Icons/LongMessageIcons.tsx b/web/app/src/components/ui/Icons/LongMessageIcons.tsx new file mode 100644 index 00000000..401121ce --- /dev/null +++ b/web/app/src/components/ui/Icons/LongMessageIcons.tsx @@ -0,0 +1,39 @@ +import type { SVGProps } from "react"; + +type LongMessageIconProps = SVGProps & { + size?: number | string; +}; + +function iconSize(size: number | string | undefined) { + return size ?? 24; +} + +export function LongMessageExpandIcon({ size, ...props }: LongMessageIconProps) { + const resolvedSize = iconSize(size); + return ( + + + + ); +} + +export function LongMessageCollapseIcon({ size, ...props }: LongMessageIconProps) { + const resolvedSize = iconSize(size); + return ( + + + + ); +} diff --git a/web/app/src/components/ui/Icons/index.ts b/web/app/src/components/ui/Icons/index.ts index d9cbc81f..e1aa3623 100644 --- a/web/app/src/components/ui/Icons/index.ts +++ b/web/app/src/components/ui/Icons/index.ts @@ -1,2 +1,3 @@ export * from "./IconImage"; +export * from "./LongMessageIcons"; export * from "./SidebarNavigationIcons"; diff --git a/web/app/src/shared/i18n/messages.ts b/web/app/src/shared/i18n/messages.ts index 45d19787..02aaf3fa 100644 --- a/web/app/src/shared/i18n/messages.ts +++ b/web/app/src/shared/i18n/messages.ts @@ -640,6 +640,8 @@ export const messages = { inputPlaceholder: "输入消息,使用 @ 选择成员", send: "发送", composerTip: "Enter 发送,Shift + Enter 换行。支持房间、私信和 @ 提及。", + messageLongExpand: "展开", + messageLongCollapse: "收起", profileSetupTitle: "配置 Manager Profile", profileSetupSubtitle: "自动检测没有找到可用模型。请完成 Manager 的运行配置后再开始对话。", profileProvider: "模型提供方",