Skip to content
Merged
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
3 changes: 3 additions & 0 deletions web/app/public/icons/long-message-collapse.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions web/app/public/icons/long-message-expand.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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(<LongMessageCollapse html="<p>initial long content</p>" t={t} />);

const content = document.querySelector<HTMLElement>(".long-message-content");
expect(content).not.toBeNull();
expect(content?.style.maxHeight).toBe(`${lineHeight * 8}px`);

naturalContentHeight = lineHeight * 12;
rerender(<LongMessageCollapse html="<p>initial long content with more streamed text</p>" t={t} />);

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`);
});
});
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 (
<div className={classNames("long-message-collapse", collapsed && "is-collapsed", isExpanded && "is-expanded")}>
Expand All @@ -165,7 +175,8 @@ export function LongMessageCollapse({ expanded, html, onExpandedChange, t }: Lon
className="long-message-toggle"
onClick={toggleExpanded}
>
{toggleLabel}
<ToggleIcon className="long-message-toggle-icon" aria-hidden="true" />
<span>{toggleLabel}</span>
</Button>
</div>
</div>
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function MessageContent({
);

useEffect(() => {
if (enableLongMessageCollapse && t) {
if (enableLongMessageCollapse) {
return undefined;
}

Expand All @@ -62,7 +62,7 @@ export function MessageContent({
return () => {
cancelled = true;
};
}, [enableLongMessageCollapse, markup, t]);
}, [enableLongMessageCollapse, markup]);

if (blankTurnPlaceholder) {
return (
Expand Down
39 changes: 39 additions & 0 deletions web/app/src/components/ui/Icons/LongMessageIcons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { SVGProps } from "react";

type LongMessageIconProps = SVGProps<SVGSVGElement> & {
size?: number | string;
};

function iconSize(size: number | string | undefined) {
return size ?? 24;
}

export function LongMessageExpandIcon({ size, ...props }: LongMessageIconProps) {
const resolvedSize = iconSize(size);
return (
<svg width={resolvedSize} height={resolvedSize} viewBox="0 0 24 24" fill="none" {...props}>
<path
d="M6 9L12 15L18 9"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

export function LongMessageCollapseIcon({ size, ...props }: LongMessageIconProps) {
const resolvedSize = iconSize(size);
return (
<svg width={resolvedSize} height={resolvedSize} viewBox="0 0 24 24" fill="none" {...props}>
<path
d="M6 15L12 9L18 15"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
1 change: 1 addition & 0 deletions web/app/src/components/ui/Icons/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./IconImage";
export * from "./LongMessageIcons";
export * from "./SidebarNavigationIcons";
2 changes: 2 additions & 0 deletions web/app/src/shared/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,8 @@ export const messages = {
inputPlaceholder: "输入消息,使用 @ 选择成员",
send: "发送",
composerTip: "Enter 发送,Shift + Enter 换行。支持房间、私信和 @ 提及。",
messageLongExpand: "展开",
messageLongCollapse: "收起",
profileSetupTitle: "配置 Manager Profile",
profileSetupSubtitle: "自动检测没有找到可用模型。请完成 Manager 的运行配置后再开始对话。",
profileProvider: "模型提供方",
Expand Down
Loading