Skip to content

Commit d6d5f32

Browse files
committed
Collapse long user messages by default
- Add expand/collapse controls for long user prompts - Preserve copy and revert actions in the footer - Add tests for collapsed, expanded, and short messages
1 parent d22c6f5 commit d6d5f32

3 files changed

Lines changed: 287 additions & 49 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: 105 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -342,35 +342,34 @@ function TimelineRowContent({ row }: { row: TimelineRow }) {
342342
)}
343343
</div>
344344
)}
345-
{(displayedUserMessage.visibleText.trim().length > 0 ||
346-
terminalContexts.length > 0) && (
347-
<UserMessageBody
348-
text={displayedUserMessage.visibleText}
349-
terminalContexts={terminalContexts}
350-
/>
351-
)}
352-
<div className="mt-1.5 flex items-center justify-end gap-2">
353-
<div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
354-
{displayedUserMessage.copyText && (
355-
<MessageCopyButton text={displayedUserMessage.copyText} />
356-
)}
357-
{canRevertAgentWork && (
358-
<Button
359-
type="button"
360-
size="xs"
361-
variant="outline"
362-
disabled={ctx.isRevertingCheckpoint || ctx.isWorking}
363-
onClick={() => ctx.onRevertUserMessage(row.message.id)}
364-
title="Revert to this message"
365-
>
366-
<Undo2Icon className="size-3" />
367-
</Button>
368-
)}
369-
</div>
370-
<p className="text-right text-xs text-muted-foreground/50">
371-
{formatTimestamp(row.message.createdAt, ctx.timestampFormat)}
372-
</p>
373-
</div>
345+
<CollapsibleUserMessageBody
346+
text={displayedUserMessage.visibleText}
347+
terminalContexts={terminalContexts}
348+
footer={
349+
<>
350+
<div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
351+
{displayedUserMessage.copyText && (
352+
<MessageCopyButton text={displayedUserMessage.copyText} />
353+
)}
354+
{canRevertAgentWork && (
355+
<Button
356+
type="button"
357+
size="xs"
358+
variant="outline"
359+
disabled={ctx.isRevertingCheckpoint || ctx.isWorking}
360+
onClick={() => ctx.onRevertUserMessage(row.message.id)}
361+
title="Revert to this message"
362+
>
363+
<Undo2Icon className="size-3" />
364+
</Button>
365+
)}
366+
</div>
367+
<p className="text-right text-xs text-muted-foreground/50">
368+
{formatTimestamp(row.message.createdAt, ctx.timestampFormat)}
369+
</p>
370+
</>
371+
}
372+
/>
374373
</div>
375374
</div>
376375
);
@@ -681,6 +680,83 @@ const UserMessageTerminalContextInlineLabel = memo(
681680
},
682681
);
683682

683+
const MAX_COLLAPSED_USER_MESSAGE_LINES = 8;
684+
const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600;
685+
const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75;
686+
const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`;
687+
688+
function shouldCollapseUserMessage(text: string): boolean {
689+
if (text.trim().length === 0) {
690+
return false;
691+
}
692+
693+
return (
694+
text.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH ||
695+
text.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES
696+
);
697+
}
698+
699+
const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: {
700+
text: string;
701+
terminalContexts: ParsedTerminalContextEntry[];
702+
footer?: ReactNode;
703+
}) {
704+
const [expanded, setExpanded] = useState(false);
705+
const hasVisibleBody = props.text.trim().length > 0 || props.terminalContexts.length > 0;
706+
const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text);
707+
const isCollapsed = canCollapse && !expanded;
708+
709+
return (
710+
<div>
711+
{hasVisibleBody ? (
712+
<div
713+
className={cn("relative", isCollapsed && "max-h-44 overflow-hidden")}
714+
data-user-message-body="true"
715+
data-user-message-collapsed={isCollapsed ? "true" : "false"}
716+
data-user-message-collapsible={canCollapse ? "true" : "false"}
717+
data-user-message-fade={isCollapsed ? "true" : "false"}
718+
style={
719+
isCollapsed
720+
? {
721+
WebkitMaskImage: COLLAPSED_USER_MESSAGE_FADE_MASK,
722+
maskImage: COLLAPSED_USER_MESSAGE_FADE_MASK,
723+
}
724+
: undefined
725+
}
726+
>
727+
<UserMessageBody text={props.text} terminalContexts={props.terminalContexts} />
728+
</div>
729+
) : null}
730+
{canCollapse || props.footer ? (
731+
<div
732+
className={cn(
733+
"mt-1.5 flex items-center gap-2",
734+
canCollapse && props.footer ? "justify-between" : "justify-end",
735+
)}
736+
data-user-message-footer="true"
737+
>
738+
{canCollapse ? (
739+
<Button
740+
type="button"
741+
size="xs"
742+
variant="ghost"
743+
aria-expanded={expanded}
744+
data-scroll-anchor-ignore
745+
onClick={() => setExpanded((value) => !value)}
746+
className="h-auto px-0 text-xs text-muted-foreground/70 hover:bg-transparent hover:text-foreground/85"
747+
>
748+
{expanded ? "Show less" : "Show full message"}
749+
</Button>
750+
) : null}
751+
{props.footer ? (
752+
<div className="ml-auto flex items-center gap-2">{props.footer}</div>
753+
) : null}
754+
</div>
755+
) : null}
756+
</div>
757+
);
758+
});
759+
684760
const UserMessageBody = memo(function UserMessageBody(props: {
685761
text: string;
686762
terminalContexts: ParsedTerminalContextEntry[];

0 commit comments

Comments
 (0)