| author | |
| committer | |
| log | 99efaa0faae572344f2bcb7cfc97becc6420e645 |
| tree | 7719d925f9dd5f73aad913d64884cad467bf2f4c |
| parent | 6b9feb1bd3a6a4f7da2451cf35c2efc022ab5d78 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 () => { | ... | @@ -48,6 +48,8 @@ vi.mock("@legendapp/list/react", async () => { |
| 48 | 48 | ||
| 49 | import { MessagesTimeline } from "./MessagesTimeline"; | 49 | import { MessagesTimeline } from "./MessagesTimeline"; |
| 50 | 50 | ||
| 51 | const MESSAGE_CREATED_AT = "2026-04-13T12:00:00.000Z"; | ||
| 52 | |||
| 51 | function buildProps() { | 53 | function buildProps() { |
| 52 | return { | 54 | return { |
| 53 | isWorking: false, | 55 | isWorking: false, |
| ... | @@ -73,6 +75,27 @@ function buildProps() { | ... | @@ -73,6 +75,27 @@ function buildProps() { |
| 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 | describe("MessagesTimeline", () => { | 99 | describe("MessagesTimeline", () => { |
| 77 | afterEach(() => { | 100 | afterEach(() => { |
| 78 | scrollToEndSpy.mockReset(); | 101 | scrollToEndSpy.mockReset(); |
| ... | @@ -157,4 +180,87 @@ describe("MessagesTimeline", () => { | ... | @@ -157,4 +180,87 @@ describe("MessagesTimeline", () => { |
| 157 | await screen.unmount(); | 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 | }); |
apps/web/src/components/chat/MessagesTimeline.test.tsx+76-20| ... | @@ -73,6 +73,7 @@ beforeAll(() => { | ... | @@ -73,6 +73,7 @@ beforeAll(() => { |
| 73 | }); | 73 | }); |
| 74 | 74 | ||
| 75 | const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); | 75 | const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); |
| 76 | const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z"; | ||
| 76 | 77 | ||
| 77 | function buildProps() { | 78 | function buildProps() { |
| 78 | return { | 79 | return { |
| ... | @@ -99,33 +100,73 @@ function buildProps() { | ... | @@ -99,33 +100,73 @@ function buildProps() { |
| 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 | describe("MessagesTimeline", () => { | 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 | it("renders inline terminal labels with the composer chip UI", async () => { | 153 | it("renders inline terminal labels with the composer chip UI", async () => { |
| 104 | const { MessagesTimeline } = await import("./MessagesTimeline"); | 154 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| 105 | const markup = renderToStaticMarkup( | 155 | const markup = renderToStaticMarkup( |
| 106 | <MessagesTimeline | 156 | <MessagesTimeline |
| 107 | {...buildProps()} | 157 | {...buildProps()} |
| 108 | timelineEntries={[ | 158 | timelineEntries={[ |
| 109 | { | 159 | buildUserTimelineEntry( |
| 110 | id: "entry-1", | 160 | [ |
| 111 | kind: "message", | 161 | buildLongUserMessageText("yoo what's @terminal-1:1-5 mean"), |
| 112 | createdAt: "2026-03-17T19:12:28.000Z", | 162 | "", |
| 113 | message: { | 163 | "<terminal_context>", |
| 114 | id: MessageId.make("message-2"), | 164 | "- Terminal 1 lines 1-5:", |
| 115 | role: "user", | 165 | " 1 | julius@mac effect-http-ws-cli % bun i", |
| 116 | text: [ | 166 | " 2 | bun install v1.3.9 (cf6cdbbb)", |
| 117 | "yoo what's @terminal-1:1-5 mean", | 167 | "</terminal_context>", |
| 118 | "", | 168 | ].join("\n"), |
| 119 | "<terminal_context>", | 169 | ), |
| 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 | }, | ||
| 129 | ]} | 170 | ]} |
| 130 | />, | 171 | />, |
| 131 | ); | 172 | ); |
| ... | @@ -133,8 +174,23 @@ describe("MessagesTimeline", () => { | ... | @@ -133,8 +174,23 @@ describe("MessagesTimeline", () => { |
| 133 | expect(markup).toContain("Terminal 1 lines 1-5"); | 174 | expect(markup).toContain("Terminal 1 lines 1-5"); |
| 134 | expect(markup).toContain("lucide-terminal"); | 175 | expect(markup).toContain("lucide-terminal"); |
| 135 | expect(markup).toContain("yoo what&#x27;s "); | 176 | expect(markup).toContain("yoo what&#x27;s "); |
| 177 | expect(markup).toContain("Show full message"); | ||
| 136 | }, 20_000); | 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 | it("renders context compaction entries in the normal work log", async () => { | 194 | it("renders context compaction entries in the normal work log", async () => { |
| 139 | const { MessagesTimeline } = await import("./MessagesTimeline"); | 195 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| 140 | const markup = renderToStaticMarkup( | 196 | const markup = renderToStaticMarkup( |
apps/web/src/components/chat/MessagesTimeline.tsx+100-18| ... | @@ -365,24 +365,24 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" | ... | @@ -365,24 +365,24 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 365 | ))} | 365 | ))} |
| 366 | </div> | 366 | </div> |
| 367 | )} | 367 | )} |
| 368 | {(displayedUserMessage.visibleText.trim().length > 0 || terminalContexts.length > 0) && ( | 368 | <CollapsibleUserMessageBody |
| 369 | <UserMessageBody | 369 | text={displayedUserMessage.visibleText} |
| 370 | text={displayedUserMessage.visibleText} | 370 | terminalContexts={terminalContexts} |
| 371 | terminalContexts={terminalContexts} | 371 | skills={ctx.skills} |
| 372 | skills={ctx.skills} | 372 | footer={ |
| 373 | /> | 373 | <> |
| 374 | )} | 374 | <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100"> |
| 375 | <div className="mt-1.5 flex items-center justify-end gap-2"> | 375 | {displayedUserMessage.copyText && ( |
| 376 | <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100"> | 376 | <MessageCopyButton text={displayedUserMessage.copyText} /> |
| 377 | {displayedUserMessage.copyText && ( | 377 | )} |
| 378 | <MessageCopyButton text={displayedUserMessage.copyText} /> | 378 | {canRevertAgentWork && <RevertUserMessageButton messageId={row.message.id} />} |
| 379 | )} | 379 | </div> |
| 380 | {canRevertAgentWork && <RevertUserMessageButton messageId={row.message.id} />} | 380 | <p className="text-right text-xs text-muted-foreground/50"> |
| 381 | </div> | 381 | {formatTimestamp(row.message.createdAt, ctx.timestampFormat)} |
| 382 | <p className="text-right text-xs text-muted-foreground/50"> | 382 | </p> |
| 383 | {formatTimestamp(row.message.createdAt, ctx.timestampFormat)} | 383 | </> |
| 384 | </p> | 384 | } |
| 385 | </div> | 385 | /> |
| 386 | </div> | 386 | </div> |
| 387 | </div> | 387 | </div> |
| 388 | ); | 388 | ); |
| ... | @@ -755,6 +755,88 @@ const UserMessageTerminalContextInlineLabel = memo( | ... | @@ -755,6 +755,88 @@ const UserMessageTerminalContextInlineLabel = memo( |
| 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 | const UserMessageBody = memo(function UserMessageBody(props: { | 840 | const UserMessageBody = memo(function UserMessageBody(props: { |
| 759 | text: string; | 841 | text: string; |
| 760 | terminalContexts: ParsedTerminalContextEntry[]; | 842 | terminalContexts: ParsedTerminalContextEntry[]; |