authorgravatar for k@kainoa.meKainoa Newton <k@kainoa.me> 2026-05-08 19:00:03-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-09 02:00:03+00:00
log99efaa0faae572344f2bcb7cfc97becc6420e645
tree7719d925f9dd5f73aad913d64884cad467bf2f4c
parent6b9feb1bd3a6a4f7da2451cf35c2efc022ab5d78
signature Signed by PGP key B5690EEEBB952194

Collapse long user messages by default (#2180)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

3 files changed, 282 insertions(+), 38 deletions(-)

apps/web/src/components/chat/MessagesTimeline.browser.tsx+106
......@@ -48,6 +48,8 @@ vi.mock("@legendapp/list/react", async () => {
4848
4949import { MessagesTimeline } from "./MessagesTimeline";
5050
51const MESSAGE_CREATED_AT = "2026-04-13T12:00:00.000Z";
52
5153function buildProps() {
5254 return {
5355 isWorking: false,
......@@ -73,6 +75,27 @@ function buildProps() {
7375 };
7476}
7577
78function 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
84function 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
7699describe("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+76-20
......@@ -73,6 +73,7 @@ beforeAll(() => {
7373});
7474
7575const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
76const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";
7677
7778function buildProps() {
7879 return {
......@@ -99,33 +100,73 @@ function buildProps() {
99100 };
100101}
101102
103function 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
109function 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
102124describe("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", () => {
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"
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
758const MAX_COLLAPSED_USER_MESSAGE_LINES = 8;
759const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600;
760const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75;
761const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`;
762
763function 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
774const 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
758840const UserMessageBody = memo(function UserMessageBody(props: {
759841 text: string;
760842 terminalContexts: ParsedTerminalContextEntry[];