History

Collapse long user messages by default (#2180)

Kainoa Newton committed 6 months agov0.0.23-nightly.20260509.237

Commit 99efaa01 parent 6b9feb13 files · +282 −38Browse files

Unified · Split

3 files
  1. apps/web/src/components/chat/MessagesTimeline.browser.tsx +106 −0
  2. apps/web/src/components/chat/MessagesTimeline.test.tsx +76 −20
  3. apps/web/src/components/chat/MessagesTimeline.tsx +100 −18
apps/web/src/components/chat/MessagesTimeline.browser.tsx+106−0
@@ -48,6 +48,8 @@ vi.mock("@legendapp/list/react", async () => { · 47 unmodified lines
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() { · 19 unmodified lines
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", () => { · 78 unmodified lines
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+76−20
@@ -73,6 +73,7 @@ beforeAll(() => { · 72 unmodified lines
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,33 +100,73 @@ function buildProps() { · 20 unmodified lines
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 );
@@ -133,8 +174,23 @@ describe("MessagesTimeline", () => { · 1 unmodified line
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+100−18
@@ -365,24 +365,24 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" · 364 unmodified lines
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( · 366 unmodified lines
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[];