Unified · Split
| @@ -48,6 +48,8 @@ vi.mock("@legendapp/list/react", async () => { · 47 unmodified lines | ||
| 48 | 48 | |
| 49 | 49 | import { MessagesTimeline } from "./MessagesTimeline"; |
| 50 | 50 | |
| 51 | +const MESSAGE_CREATED_AT = "2026-04-13T12:00:00.000Z"; | |
| 52 | + | |
| 51 | 53 | function buildProps() { |
| 52 | 54 | return { |
| 53 | 55 | isWorking: false, |
| @@ -73,6 +75,27 @@ function buildProps() { · 19 unmodified lines | ||
| 73 | 75 | }; |
| 74 | 76 | } |
| 75 | 77 | |
| 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 | + | |
| 76 | 99 | describe("MessagesTimeline", () => { |
| 77 | 100 | afterEach(() => { |
| 78 | 101 | scrollToEndSpy.mockReset(); |
| @@ -157,4 +180,87 @@ describe("MessagesTimeline", () => { · 78 unmodified lines | ||
| 157 | 180 | await screen.unmount(); |
| 158 | 181 | } |
| 159 | 182 | }); |
| 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 | + }); | |
| 160 | 266 | }); |
| @@ -73,6 +73,7 @@ beforeAll(() => { · 72 unmodified lines | ||
| 73 | 73 | }); |
| 74 | 74 | |
| 75 | 75 | const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); |
| 76 | +const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z"; | |
| 76 | 77 | |
| 77 | 78 | function buildProps() { |
| 78 | 79 | return { |
| @@ -99,33 +100,73 @@ function buildProps() { · 20 unmodified lines | ||
| 99 | 100 | }; |
| 100 | 101 | } |
| 101 | 102 | |
| 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 | + | |
| 102 | 124 | 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 | + | |
| 103 | 153 | it("renders inline terminal labels with the composer chip UI", async () => { |
| 104 | 154 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| 105 | 155 | const markup = renderToStaticMarkup( |
| 106 | 156 | <MessagesTimeline |
| 107 | 157 | {...buildProps()} |
| 108 | 158 | 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 | + ), | |
| 129 | 170 | ]} |
| 130 | 171 | />, |
| 131 | 172 | ); |
| @@ -133,8 +174,23 @@ describe("MessagesTimeline", () => { · 1 unmodified line | ||
| 133 | 174 | expect(markup).toContain("Terminal 1 lines 1-5"); |
| 134 | 175 | expect(markup).toContain("lucide-terminal"); |
| 135 | 176 | expect(markup).toContain("yoo what's "); |
| 177 | + expect(markup).toContain("Show full message"); | |
| 136 | 178 | }, 20_000); |
| 137 | 179 | |
| 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 | + | |
| 138 | 194 | it("renders context compaction entries in the normal work log", async () => { |
| 139 | 195 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| 140 | 196 | const markup = renderToStaticMarkup( |
| @@ -365,24 +365,24 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" · 364 unmodified lines | ||
| 365 | 365 | ))} |
| 366 | 366 | </div> |
| 367 | 367 | )} |
| 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 | + /> | |
| 386 | 386 | </div> |
| 387 | 387 | </div> |
| 388 | 388 | ); |
| @@ -755,6 +755,88 @@ const UserMessageTerminalContextInlineLabel = memo( · 366 unmodified lines | ||
| 755 | 755 | }, |
| 756 | 756 | ); |
| 757 | 757 | |
| 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 | + | |
| 758 | 840 | const UserMessageBody = memo(function UserMessageBody(props: { |
| 759 | 841 | text: string; |
| 760 | 842 | terminalContexts: ParsedTerminalContextEntry[]; |