Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ vi.mock("@legendapp/list/react", async () => {

import { MessagesTimeline } from "./MessagesTimeline";

const MESSAGE_CREATED_AT = "2026-04-13T12:00:00.000Z";

function buildProps() {
return {
isWorking: false,
Expand All @@ -73,6 +75,27 @@ function buildProps() {
};
}

function buildLongUserMessageText(tail = "deep hidden detail only after expand") {
return Array.from({ length: 9 }, (_, index) =>
index === 8 ? tail : `Line ${index + 1}: ${"verbose prompt content ".repeat(8).trim()}`,
).join("\n");
}

function buildUserTimelineEntry(text: string) {
return {
id: "entry-1",
kind: "message" as const,
createdAt: MESSAGE_CREATED_AT,
message: {
id: "message-1" as never,
role: "user" as const,
text,
createdAt: MESSAGE_CREATED_AT,
streaming: false,
},
};
}

describe("MessagesTimeline", () => {
afterEach(() => {
scrollToEndSpy.mockReset();
Expand Down Expand Up @@ -157,4 +180,87 @@ describe("MessagesTimeline", () => {
await screen.unmount();
}
});

it("starts long user messages collapsed by default", async () => {
const screen = await render(
<MessagesTimeline
{...buildProps()}
timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText())]}
/>,
);

try {
const toggle = page.getByRole("button", { name: "Show full message" });
await expect.element(toggle).toBeVisible();
await expect.element(toggle).toHaveAttribute("aria-expanded", "false");

const messageBody = document.querySelector(
"[data-user-message-body='true']",
) as HTMLDivElement | null;
expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("true");
expect(messageBody?.className).toContain("max-h-44");
expect(messageBody?.className).toContain("overflow-hidden");
expect(messageBody?.getAttribute("data-user-message-fade")).toBe("true");
expect(messageBody?.style.maskImage).toContain("linear-gradient");
} finally {
await screen.unmount();
}
});

it("expands and re-collapses long user messages from the toggle", async () => {
const screen = await render(
<MessagesTimeline
{...buildProps()}
timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText())]}
/>,
);

try {
const expandButton = page.getByRole("button", { name: "Show full message" });
await expect.element(expandButton).toBeVisible();

expect(document.body.textContent ?? "").toContain("deep hidden detail only after expand");

await expandButton.click();

const collapseButton = page.getByRole("button", { name: "Show less" });
await expect.element(collapseButton).toBeVisible();
await expect.element(collapseButton).toHaveAttribute("aria-expanded", "true");

let messageBody = document.querySelector("[data-user-message-body='true']");
expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("false");
expect(messageBody?.className).not.toContain("max-h-44");
expect(messageBody?.getAttribute("data-user-message-fade")).toBe("false");
expect((messageBody as HTMLDivElement | null)?.style.maskImage ?? "").toBe("");

await collapseButton.click();

await expect.element(page.getByRole("button", { name: "Show full message" })).toBeVisible();
messageBody = document.querySelector("[data-user-message-body='true']");
expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("true");
expect(messageBody?.className).toContain("max-h-44");
expect(messageBody?.getAttribute("data-user-message-fade")).toBe("true");
expect((messageBody as HTMLDivElement | null)?.style.maskImage).toContain("linear-gradient");
} finally {
await screen.unmount();
}
});

it("starts the newest long user prompt collapsed", async () => {
const screen = await render(
<MessagesTimeline
{...buildProps()}
timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText("latest long prompt"))]}
/>,
);

try {
await expect.element(page.getByRole("button", { name: "Show full message" })).toBeVisible();

const messageBody = document.querySelector("[data-user-message-body='true']");
expect(messageBody?.getAttribute("data-user-message-collapsed")).toBe("true");
} finally {
await screen.unmount();
}
});
});
96 changes: 76 additions & 20 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ beforeAll(() => {
});

const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";

function buildProps() {
return {
Expand All @@ -99,42 +100,97 @@ function buildProps() {
};
}

function buildLongUserMessageText(tail = "deep hidden detail only after expand") {
return Array.from({ length: 9 }, (_, index) =>
index === 8 ? tail : `Line ${index + 1}: ${"verbose prompt content ".repeat(8).trim()}`,
).join("\n");
}

function buildUserTimelineEntry(text: string) {
return {
id: "entry-1",
kind: "message" as const,
createdAt: MESSAGE_CREATED_AT,
message: {
id: MessageId.make("message-1"),
role: "user" as const,
text,
createdAt: MESSAGE_CREATED_AT,
streaming: false,
},
};
}

describe("MessagesTimeline", () => {
it("renders collapse controls for long user messages", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText())]}
/>,
);

expect(markup).toContain("Show full message");
expect(markup).toContain('data-user-message-collapsed="true"');
expect(markup).toContain('data-user-message-fade="true"');
expect(markup).toContain('data-user-message-footer="true"');
});

it("does not render collapse controls for short user messages", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[buildUserTimelineEntry("Short prompt.")]}
/>,
);

expect(markup).not.toContain("Show full message");
expect(markup).toContain('data-user-message-collapsible="false"');
});

it("renders inline terminal labels with the composer chip UI", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[
{
id: "entry-1",
kind: "message",
createdAt: "2026-03-17T19:12:28.000Z",
message: {
id: MessageId.make("message-2"),
role: "user",
text: [
"yoo what's @terminal-1:1-5 mean",
"",
"<terminal_context>",
"- Terminal 1 lines 1-5:",
" 1 | julius@mac effect-http-ws-cli % bun i",
" 2 | bun install v1.3.9 (cf6cdbbb)",
"</terminal_context>",
].join("\n"),
createdAt: "2026-03-17T19:12:28.000Z",
streaming: false,
},
},
buildUserTimelineEntry(
[
buildLongUserMessageText("yoo what's @terminal-1:1-5 mean"),
"",
"<terminal_context>",
"- Terminal 1 lines 1-5:",
" 1 | julius@mac effect-http-ws-cli % bun i",
" 2 | bun install v1.3.9 (cf6cdbbb)",
"</terminal_context>",
].join("\n"),
),
]}
/>,
);

expect(markup).toContain("Terminal 1 lines 1-5");
expect(markup).toContain("lucide-terminal");
expect(markup).toContain("yoo what&#x27;s ");
expect(markup).toContain("Show full message");
}, 20_000);

it("keeps the copy button for collapsed long user messages", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
timelineEntries={[buildUserTimelineEntry(buildLongUserMessageText())]}
/>,
);

expect(markup).toContain('aria-label="Copy link"');
expect(markup).toContain('data-user-message-collapsed="true"');
expect(markup).toContain('data-user-message-footer="true"');
});

it("renders context compaction entries in the normal work log", async () => {
const { MessagesTimeline } = await import("./MessagesTimeline");
const markup = renderToStaticMarkup(
Expand Down
118 changes: 100 additions & 18 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -365,24 +365,24 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
))}
</div>
)}
{(displayedUserMessage.visibleText.trim().length > 0 || terminalContexts.length > 0) && (
<UserMessageBody
text={displayedUserMessage.visibleText}
terminalContexts={terminalContexts}
skills={ctx.skills}
/>
)}
<div className="mt-1.5 flex items-center justify-end gap-2">
<div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
{displayedUserMessage.copyText && (
<MessageCopyButton text={displayedUserMessage.copyText} />
)}
{canRevertAgentWork && <RevertUserMessageButton messageId={row.message.id} />}
</div>
<p className="text-right text-xs text-muted-foreground/50">
{formatTimestamp(row.message.createdAt, ctx.timestampFormat)}
</p>
</div>
<CollapsibleUserMessageBody
text={displayedUserMessage.visibleText}
terminalContexts={terminalContexts}
skills={ctx.skills}
footer={
<>
<div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
{displayedUserMessage.copyText && (
<MessageCopyButton text={displayedUserMessage.copyText} />
)}
{canRevertAgentWork && <RevertUserMessageButton messageId={row.message.id} />}
</div>
<p className="text-right text-xs text-muted-foreground/50">
{formatTimestamp(row.message.createdAt, ctx.timestampFormat)}
</p>
</>
}
/>
</div>
</div>
);
Expand Down Expand Up @@ -755,6 +755,88 @@ const UserMessageTerminalContextInlineLabel = memo(
},
);

const MAX_COLLAPSED_USER_MESSAGE_LINES = 8;
const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600;
const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75;
const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`;

function shouldCollapseUserMessage(text: string): boolean {
if (text.trim().length === 0) {
return false;
}

return (
text.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH ||
text.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES
);
}

const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: {
text: string;
terminalContexts: ParsedTerminalContextEntry[];
skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
footer?: ReactNode;
}) {
const [expanded, setExpanded] = useState(false);
const hasVisibleBody = props.text.trim().length > 0 || props.terminalContexts.length > 0;
const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text);
const isCollapsed = canCollapse && !expanded;

return (
<div>
{hasVisibleBody ? (
<div
className={cn("relative", isCollapsed && "max-h-44 overflow-hidden")}
data-user-message-body="true"
data-user-message-collapsed={isCollapsed ? "true" : "false"}
data-user-message-collapsible={canCollapse ? "true" : "false"}
data-user-message-fade={isCollapsed ? "true" : "false"}
style={
isCollapsed
? {
WebkitMaskImage: COLLAPSED_USER_MESSAGE_FADE_MASK,
maskImage: COLLAPSED_USER_MESSAGE_FADE_MASK,
}
: undefined
}
>
<UserMessageBody
text={props.text}
terminalContexts={props.terminalContexts}
skills={props.skills}
/>
</div>
) : null}
{canCollapse || props.footer ? (
<div
className={cn(
"mt-1.5 flex items-center gap-2",
canCollapse && props.footer ? "justify-between" : "justify-end",
)}
data-user-message-footer="true"
>
{canCollapse ? (
<Button
type="button"
size="xs"
variant="ghost"
aria-expanded={expanded}
data-scroll-anchor-ignore
onClick={() => setExpanded((value) => !value)}
className="-ml-1 h-6 rounded-md px-1.5 text-xs text-muted-foreground/72 hover:bg-muted/55 hover:text-foreground/85"
>
{expanded ? "Show less" : "Show full message"}
</Button>
) : null}
{props.footer ? (
<div className="ml-auto flex items-center gap-2">{props.footer}</div>
) : null}
</div>
) : null}
</div>
);
});

const UserMessageBody = memo(function UserMessageBody(props: {
text: string;
terminalContexts: ParsedTerminalContextEntry[];
Expand Down
Loading