Skip to content

Commit 99efaa0

Browse files
Collapse long user messages by default (#2180)
Co-authored-by: Julius Marminge <julius0216@outlook.com>
1 parent 6b9feb1 commit 99efaa0

3 files changed

Lines changed: 282 additions & 38 deletions

File tree

apps/web/src/components/chat/MessagesTimeline.browser.tsx

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ vi.mock("@legendapp/list/react", async () => {
4848

4949
import { MessagesTimeline } from "./MessagesTimeline";
5050

51+
const MESSAGE_CREATED_AT = "2026-04-13T12:00:00.000Z";
52+
5153
function buildProps() {
5254
return {
5355
isWorking: false,
@@ -73,6 +75,27 @@ function buildProps() {
7375
};
7476
}
7577

78+
function buildLongUserMessageText(tail = "deep hidden detail only after expand") {
79+
return Array.from({ length: 9 }, (_, index) =>
80+
index === 8 ? tail : `Line ${index + 1}: ${"verbose prompt content ".repeat(8).trim()}`,
81+
).join("\n");
82+
}
83+
84+
function buildUserTimelineEntry(text: string) {
85+
return {
86+
id: "entry-1",
87+
kind: "message" as const,
88+
createdAt: MESSAGE_CREATED_AT,
89+
message: {
90+
id: "message-1" as never,
91+
role: "user" as const,
92+
text,
93+
createdAt: MESSAGE_CREATED_AT,
94+
streaming: false,
95+
},
96+
};
97+
}
98+
7699
describe("MessagesTimeline", () => {
77100
afterEach(() => {
78101
scrollToEndSpy.mockReset();
@@ -157,4 +180,87 @@ describe("MessagesTimeline", () => {
157180
await screen.unmount();
158181
}
159182
});
183+
184+
it("starts long user messages collapsed by default", async () => {
185+
const screen = await render(
186+
<MessagesTimeline
187+
{...buildProps()}
188+
timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText())]}
189+
/>,
190+
);
191+
192+
try {
193+
const toggle = page.getByRole("button", { name: "Show full message" });
194+
await expect.element(toggle).toBeVisible();
195+
await expect.element(toggle).toHaveAttribute("aria-expanded", "false");
196+
197+
const messageBody = document.querySelector(
198+
"[data-user-message-body='true']",
199+
) as HTMLDivElement | null;
200+
expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("true");
201+
expect(messageBody?.className).toContain("max-h-44");
202+
expect(messageBody?.className).toContain("overflow-hidden");
203+
expect(messageBody?.getAttribute("data-user-message-fade")).toBe("true");
204+
expect(messageBody?.style.maskImage).toContain("linear-gradient");
205+
} finally {
206+
await screen.unmount();
207+
}
208+
});
209+
210+
it("expands and re-collapses long user messages from the toggle", async () => {
211+
const screen = await render(
212+
<MessagesTimeline
213+
{...buildProps()}
214+
timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText())]}
215+
/>,
216+
);
217+
218+
try {
219+
const expandButton = page.getByRole("button", { name: "Show full message" });
220+
await expect.element(expandButton).toBeVisible();
221+
222+
expect(document.body.textContent ?? "").toContain("deep hidden detail only after expand");
223+
224+
await expandButton.click();
225+
226+
const collapseButton = page.getByRole("button", { name: "Show less" });
227+
await expect.element(collapseButton).toBeVisible();
228+
await expect.element(collapseButton).toHaveAttribute("aria-expanded", "true");
229+
230+
let messageBody = document.querySelector("[data-user-message-body='true']");
231+
expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("false");
232+
expect(messageBody?.className).not.toContain("max-h-44");
233+
expect(messageBody?.getAttribute("data-user-message-fade")).toBe("false");
234+
expect((messageBody as HTMLDivElement | null)?.style.maskImage ?? "").toBe("");
235+
236+
await collapseButton.click();
237+
238+
await expect.element(page.getByRole("button", { name: "Show full message" })).toBeVisible();
239+
messageBody = document.querySelector("[data-user-message-body='true']");
240+
expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("true");
241+
expect(messageBody?.className).toContain("max-h-44");
242+
expect(messageBody?.getAttribute("data-user-message-fade")).toBe("true");
243+
expect((messageBody as HTMLDivElement | null)?.style.maskImage).toContain("linear-gradient");
244+
} finally {
245+
await screen.unmount();
246+
}
247+
});
248+
249+
it("starts the newest long user prompt collapsed", async () => {
250+
const screen = await render(
251+
<MessagesTimeline
252+
{...buildProps()}
253+
timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText("latest long prompt"))]}
254+
/>,
255+
);
256+
257+
try {
258+
await expect.element(page.getByRole("button", { name: "Show full message" })).toBeVisible();
259+
260+
const messageBody = document.querySelector("[data-user-message-body='true']");
261+
expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("true");
262+
} finally {
263+
await screen.unmount();
264+
}
265+
});
160266
});

apps/web/src/components/chat/MessagesTimeline.test.tsx

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ beforeAll(() => {
7373
});
7474

7575
const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
76+
const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";
7677

7778
function buildProps() {
7879
return {
@@ -99,42 +100,97 @@ function buildProps() {
99100
};
100101
}
101102

103+
function buildLongUserMessageText(tail = "deep hidden detail only after expand") {
104+
return Array.from({ length: 9 }, (_, index) =>
105+
index === 8 ? tail : `Line ${index + 1}: ${"verbose prompt content ".repeat(8).trim()}`,
106+
).join("\n");
107+
}
108+
109+
function buildUserTimelineEntry(text: string) {
110+
return {
111+
id: "entry-1",
112+
kind: "message" as const,
113+
createdAt: MESSAGE_CREATED_AT,
114+
message: {
115+
id: MessageId.make("message-1"),
116+
role: "user" as const,
117+
text,
118+
createdAt: MESSAGE_CREATED_AT,
119+
streaming: false,
120+
},
121+
};
122+
}
123+
102124
describe("MessagesTimeline", () => {
125+
it("renders collapse controls for long user messages", async () => {
126+
const { MessagesTimeline } = await import("./MessagesTimeline");
127+
const markup = renderToStaticMarkup(
128+
<MessagesTimeline
129+
{...buildProps()}
130+
timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText())]}
131+
/>,
132+
);
133+
134+
expect(markup).toContain("Show full message");
135+
expect(markup).toContain('data-user-message-collapsed="true"');
136+
expect(markup).toContain('data-user-message-fade="true"');
137+
expect(markup).toContain('data-user-message-footer="true"');
138+
});
139+
140+
it("does not render collapse controls for short user messages", async () => {
141+
const { MessagesTimeline } = await import("./MessagesTimeline");
142+
const markup = renderToStaticMarkup(
143+
<MessagesTimeline
144+
{...buildProps()}
145+
timelineEntries={[buildUserTimelineEntry("Short prompt.")]}
146+
/>,
147+
);
148+
149+
expect(markup).not.toContain("Show full message");
150+
expect(markup).toContain('data-user-message-collapsible="false"');
151+
});
152+
103153
it("renders inline terminal labels with the composer chip UI", async () => {
104154
const { MessagesTimeline } = await import("./MessagesTimeline");
105155
const markup = renderToStaticMarkup(
106156
<MessagesTimeline
107157
{...buildProps()}
108158
timelineEntries={[
109-
{
110-
id: "entry-1",
111-
kind: "message",
112-
createdAt: "2026-03-17T19:12:28.000Z",
113-
message: {
114-
id: MessageId.make("message-2"),
115-
role: "user",
116-
text: [
117-
"yoo what's @terminal-1:1-5 mean",
118-
"",
119-
"<terminal_context>",
120-
"- Terminal 1 lines 1-5:",
121-
" 1 | julius@mac effect-http-ws-cli % bun i",
122-
" 2 | bun install v1.3.9 (cf6cdbbb)",
123-
"</terminal_context>",
124-
].join("\n"),
125-
createdAt: "2026-03-17T19:12:28.000Z",
126-
streaming: false,
127-
},
128-
},
159+
buildUserTimelineEntry(
160+
[
161+
buildLongUserMessageText("yoo what's @terminal-1:1-5 mean"),
162+
"",
163+
"<terminal_context>",
164+
"- Terminal 1 lines 1-5:",
165+
" 1 | julius@mac effect-http-ws-cli % bun i",
166+
" 2 | bun install v1.3.9 (cf6cdbbb)",
167+
"</terminal_context>",
168+
].join("\n"),
169+
),
129170
]}
130171
/>,
131172
);
132173

133174
expect(markup).toContain("Terminal 1 lines 1-5");
134175
expect(markup).toContain("lucide-terminal");
135176
expect(markup).toContain("yoo what&#x27;s ");
177+
expect(markup).toContain("Show full message");
136178
}, 20_000);
137179

180+
it("keeps the copy button for collapsed long user messages", async () => {
181+
const { MessagesTimeline } = await import("./MessagesTimeline");
182+
const markup = renderToStaticMarkup(
183+
<MessagesTimeline
184+
{...buildProps()}
185+
timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText())]}
186+
/>,
187+
);
188+
189+
expect(markup).toContain('aria-label="Copy link"');
190+
expect(markup).toContain('data-user-message-collapsed="true"');
191+
expect(markup).toContain('data-user-message-footer="true"');
192+
});
193+
138194
it("renders context compaction entries in the normal work log", async () => {
139195
const { MessagesTimeline } = await import("./MessagesTimeline");
140196
const markup = renderToStaticMarkup(

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 100 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -365,24 +365,24 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
365365
))}
366366
</div>
367367
)}
368-
{(displayedUserMessage.visibleText.trim().length > 0 || terminalContexts.length > 0) && (
369-
<UserMessageBody
370-
text={displayedUserMessage.visibleText}
371-
terminalContexts={terminalContexts}
372-
skills={ctx.skills}
373-
/>
374-
)}
375-
<div className="mt-1.5 flex items-center justify-end gap-2">
376-
<div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
377-
{displayedUserMessage.copyText && (
378-
<MessageCopyButton text={displayedUserMessage.copyText} />
379-
)}
380-
{canRevertAgentWork && <RevertUserMessageButton messageId={row.message.id} />}
381-
</div>
382-
<p className="text-right text-xs text-muted-foreground/50">
383-
{formatTimestamp(row.message.createdAt, ctx.timestampFormat)}
384-
</p>
385-
</div>
368+
<CollapsibleUserMessageBody
369+
text={displayedUserMessage.visibleText}
370+
terminalContexts={terminalContexts}
371+
skills={ctx.skills}
372+
footer={
373+
<>
374+
<div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
375+
{displayedUserMessage.copyText && (
376+
<MessageCopyButton text={displayedUserMessage.copyText} />
377+
)}
378+
{canRevertAgentWork && <RevertUserMessageButton messageId={row.message.id} />}
379+
</div>
380+
<p className="text-right text-xs text-muted-foreground/50">
381+
{formatTimestamp(row.message.createdAt, ctx.timestampFormat)}
382+
</p>
383+
</>
384+
}
385+
/>
386386
</div>
387387
</div>
388388
);
@@ -755,6 +755,88 @@ const UserMessageTerminalContextInlineLabel = memo(
755755
},
756756
);
757757

758+
const MAX_COLLAPSED_USER_MESSAGE_LINES = 8;
759+
const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600;
760+
const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75;
761+
const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`;
762+
763+
function shouldCollapseUserMessage(text: string): boolean {
764+
if (text.trim().length === 0) {
765+
return false;
766+
}
767+
768+
return (
769+
text.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH ||
770+
text.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES
771+
);
772+
}
773+
774+
const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: {
775+
text: string;
776+
terminalContexts: ParsedTerminalContextEntry[];
777+
skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
778+
footer?: ReactNode;
779+
}) {
780+
const [expanded, setExpanded] = useState(false);
781+
const hasVisibleBody = props.text.trim().length > 0 || props.terminalContexts.length > 0;
782+
const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text);
783+
const isCollapsed = canCollapse && !expanded;
784+
785+
return (
786+
<div>
787+
{hasVisibleBody ? (
788+
<div
789+
className={cn("relative", isCollapsed && "max-h-44 overflow-hidden")}
790+
data-user-message-body="true"
791+
data-user-message-collapsed={isCollapsed ? "true" : "false"}
792+
data-user-message-collapsible={canCollapse ? "true" : "false"}
793+
data-user-message-fade={isCollapsed ? "true" : "false"}
794+
style={
795+
isCollapsed
796+
? {
797+
WebkitMaskImage: COLLAPSED_USER_MESSAGE_FADE_MASK,
798+
maskImage: COLLAPSED_USER_MESSAGE_FADE_MASK,
799+
}
800+
: undefined
801+
}
802+
>
803+
<UserMessageBody
804+
text={props.text}
805+
terminalContexts={props.terminalContexts}
806+
skills={props.skills}
807+
/>
808+
</div>
809+
) : null}
810+
{canCollapse || props.footer ? (
811+
<div
812+
className={cn(
813+
"mt-1.5 flex items-center gap-2",
814+
canCollapse && props.footer ? "justify-between" : "justify-end",
815+
)}
816+
data-user-message-footer="true"
817+
>
818+
{canCollapse ? (
819+
<Button
820+
type="button"
821+
size="xs"
822+
variant="ghost"
823+
aria-expanded={expanded}
824+
data-scroll-anchor-ignore
825+
onClick={() => setExpanded((value) => !value)}
826+
className="-ml-1 h-6 rounded-md px-1.5 text-xs text-muted-foreground/72 hover:bg-muted/55 hover:text-foreground/85"
827+
>
828+
{expanded ? "Show less" : "Show full message"}
829+
</Button>
830+
) : null}
831+
{props.footer ? (
832+
<div className="ml-auto flex items-center gap-2">{props.footer}</div>
833+
) : null}
834+
</div>
835+
) : null}
836+
</div>
837+
);
838+
});
839+
758840
const UserMessageBody = memo(function UserMessageBody(props: {
759841
text: string;
760842
terminalContexts: ParsedTerminalContextEntry[];

0 commit comments

Comments
 (0)