fix(agents): restore the conversation card layout (#2089)

* fix(agents): restore the conversation card layout

* fix(agents): group consecutive author rows and anchor message footers
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-11-08 12:37:57 +08:00
1 parent 06a7aedb66
commit 6d37f0876c
5 files changed
+199 -78

No files matched your search

+33 -6
View File
@@ -23,6 +23,17 @@ const task: AgentTask = {
const command = `python3 -c 'import pathlib\np = pathlib.Path("note.txt")\np.write_text("${"long content ".repeat(80)}")'`;
const entries: AgentEntry[] = [
{ type: "user", text: "Write a note with Python.", attachments: [], steered: false },
{ type: "thought", text: "I need approval to write." },
{
type: "tool",
id: "inspect",
title: "Read README.md",
kind: "read",
status: "completed",
locations: [],
output: null,
diffs: [],
},
{ type: "agent", text: "I will request approval before writing the note." },
{
type: "tool",
@@ -50,7 +61,7 @@ const entries: AgentEntry[] = [
for (const theme of ["dark", "light"] as const) {
for (const width of [760, 1400]) {
test(`approval stays inside a flat transcript at ${width}px in ${theme}`, async ({ page }) => {
test(`approval stays inside the agent column at ${width}px in ${theme}`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto("/");
await page.getByRole("tree", { name: "Project" }).waitFor();
@@ -74,7 +85,7 @@ for (const theme of ["dark", "light"] as const) {
await expect(card.locator("pre")).toHaveText(command);
await expect(card.getByRole("button")).toHaveText(["Allow Once", "Always Allow", "Decline", "Decline and Stop"]);
const geometry = await card.evaluate((el) => {
const transcript = el.parentElement!;
const transcript = el.parentElement!.parentElement!;
const prompt = transcript.querySelector('[class*="_user_"]')!;
const reply = transcript.querySelector('[class*="_agentCard_"]')!;
const rect = el.getBoundingClientRect();
@@ -86,15 +97,31 @@ for (const theme of ["dark", "light"] as const) {
promptRadius: parseFloat(getComputedStyle(prompt).borderRadius),
replyBackground: getComputedStyle(reply).backgroundColor,
replyBorder: getComputedStyle(reply).borderTopWidth,
leftAligned: Math.abs(prompt.getBoundingClientRect().left - reply.getBoundingClientRect().left) < 1,
userOnRight: prompt.getBoundingClientRect().right > reply.getBoundingClientRect().right,
approvalAligned: Math.abs(rect.left - reply.getBoundingClientRect().left) < 1,
authorBeforeWork:
transcript.querySelector('[class*="_agentAuthor_"]')?.nextElementSibling?.matches("details") ?? false,
footerAligned:
Math.abs(prompt.getBoundingClientRect().right - prompt.nextElementSibling!.getBoundingClientRect().right) <
1,
cardWidth: rect.width,
available: bounds.width,
actionsVisible: getComputedStyle(reply.nextElementSibling!.querySelector('[class*="_messageActions_"]')!)
.opacity,
};
});
expect(geometry.scroll).toBe(geometry.client);
expect(geometry.contained).toBe(true);
expect(geometry.promptRadius).toBeLessThanOrEqual(4);
expect(geometry.replyBackground).toBe("rgba(0, 0, 0, 0)");
expect(geometry.replyBorder).toBe("0px");
expect(geometry.leftAligned).toBe(true);
expect(geometry.replyBackground).not.toBe("rgba(0, 0, 0, 0)");
expect(geometry.replyBorder).toBe("1px");
expect(geometry.userOnRight).toBe(true);
expect(geometry.approvalAligned).toBe(true);
expect(geometry.authorBeforeWork).toBe(true);
expect(geometry.footerAligned).toBe(true);
// The last message stays actionable even while an approval follows it.
expect(geometry.actionsVisible).toBe("1");
if (width === 760) expect(geometry.cardWidth).toBeGreaterThan(geometry.available * 0.8);
await card.locator("pre").focus();
for (const label of ["Allow Once", "Always Allow", "Decline", "Decline and Stop"]) {
await page.keyboard.press("Tab");
+7 -7
View File
@@ -165,7 +165,7 @@ test("copies reply Markdown with the keyboard and keeps code copying separate",
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
const actions = copy.locator("..");
await page.mouse.move(0, 0);
await expect(actions).toHaveCSS("opacity", "0");
await expect(actions).toHaveCSS("opacity", "1");
await copy.focus();
await expect(actions).toHaveCSS("opacity", "1");
await page.keyboard.press("Enter");
@@ -175,19 +175,19 @@ test("copies reply Markdown with the keyboard and keeps code copying separate",
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("const a = 1;");
});
test("shows message actions on hover without shifting content and quotes into the draft", async ({ page }) => {
test("keeps latest reply actions visible and quotes into the draft without shifting content", async ({ page }) => {
const reply = await openReply(page, REPLY);
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
const actions = copy.locator("..");
await page.mouse.move(0, 0);
await expect(actions).toHaveCSS("opacity", "0");
await expect(actions).toHaveCSS("opacity", "1");
const before = await reply.boundingBox();
await reply.hover();
await expect(actions).toHaveCSS("opacity", "1");
expect(await reply.boundingBox()).toEqual(before);
await expect(actions.getByRole("button")).toHaveCount(4);
await page.mouse.move(0, 0);
await expect(actions).toHaveCSS("opacity", "0");
await expect(actions).toHaveCSS("opacity", "1");
const userCopy = page.getByRole("button", { name: "Copy", exact: true });
await expect(userCopy.locator("..")).toHaveCSS("opacity", "0");
await page.getByText("Show Markdown", { exact: true }).hover();
@@ -258,8 +258,8 @@ test("keeps chat history readable and selections free of timestamps and status l
});
expect(geometry.reply).toBeGreaterThan(0);
expect(geometry.reply).toBeLessThan(geometry.root);
expect(geometry.bubbleLeft).toBe(geometry.replyLeft);
expect(geometry.bubbleRight).toBe(geometry.replyRight);
expect(geometry.bubbleLeft).toBeGreaterThan(geometry.replyLeft);
expect(geometry.bubbleRight).toBeGreaterThan(geometry.replyRight);
expect(Math.abs(geometry.columnCenter - geometry.composerCenter)).toBeLessThan(1);
await stream.getByText("hi", { exact: true }).hover();
await expect(stream.locator("[class*='_messageTime_']").first()).toBeVisible();
@@ -295,7 +295,7 @@ test("keeps chat history readable and selections free of timestamps and status l
}));
expect(narrow.width).toBeGreaterThan(0);
expect(narrow.content).toBeLessThanOrEqual(narrow.width);
expect(narrow.reply).toBe(narrow.width - narrow.padding);
expect(narrow.reply).toBeLessThanOrEqual((narrow.width - narrow.padding) * 0.96);
});
test("keeps hover actions reachable when the pointer leaves the message body", async ({ page, context }) => {
+51 -25
View File
@@ -1,5 +1,6 @@
.session {
--session-content-width: calc(var(--agent-conversation-width) - var(--space-4) * 6);
--session-content-width: var(--agent-conversation-width);
--session-card-width: 80%;
flex: 1;
min-height: 0;
min-width: 0;
@@ -11,6 +12,7 @@
}
.conversation {
container-type: inline-size;
display: flex;
flex-direction: column;
min-width: 0;
@@ -111,25 +113,19 @@
min-width: 0;
width: min(100%, var(--session-content-width));
max-width: 100%;
margin-inline: 0;
margin-inline: auto;
box-sizing: border-box;
}
/* Messages and folded process rows share the tool-window gutter. */
.conversationRow {
min-width: 0;
}
/* Keep turns distinct and the work within a turn close to its author. */
.stream > * + * {
margin-top: var(--space-3);
margin-top: var(--space-4);
}
.stream > * + .process {
.stream > [data-process] + [data-process] {
margin-top: var(--space-2);
}
.stream > .process + * {
margin-top: var(--space-2);
}
.stream > * + .agentMessage[data-continuation] {
margin-top: var(--space-2);
}
.stream > .process + .process {
margin-top: 2px;
}
.stream:focus {
outline: none;
}
@@ -182,11 +178,19 @@
.messageHead > .role {
margin-bottom: 0;
}
/* User prompts stay in the same column as replies and tool calls. */
/* User cards and their footers share a right edge. */
.userMessage {
display: flex;
flex-direction: column;
align-items: stretch;
align-items: flex-end;
}
.userMessage > .user {
width: fit-content;
max-width: min(var(--session-card-width), 720px);
}
.userMessage > .messageHead,
.userMessage > .messageFooter {
justify-content: flex-end;
}
/* The actions and time below a message, in flow so they never cover the next entry. */
.messageFooter {
@@ -197,9 +201,6 @@
max-width: 100%;
flex-wrap: wrap;
}
.messageFooter > .messageTime {
order: -1;
}
/* Who wrote the message. */
.role {
display: flex;
@@ -214,14 +215,38 @@
.agentMessage {
display: flex;
flex-direction: column;
align-items: stretch;
align-items: flex-start;
}
.agentAuthor {
display: flex;
align-items: center;
min-height: var(--h-control-small);
}
.agentAuthor > .role {
margin-bottom: 0;
}
.agentAuthor + * {
margin-top: calc(var(--space-2) / 2);
}
.conversationRow > .process,
.conversationRow > .permissionLine {
max-width: min(var(--session-card-width), calc(var(--session-content-width) * 0.8), 760px);
}
.agentMessage > .messageHead,
.agentCard {
box-sizing: border-box;
width: 100%;
width: fit-content;
max-width: min(var(--session-card-width), calc(var(--session-content-width) * 0.8), 760px);
min-width: 0;
overflow-wrap: anywhere;
padding: var(--space-2) calc(var(--space-2) + var(--space-2) / 4);
border: 1px solid var(--border);
border-radius: var(--radius-small);
background: var(--bg-elev);
}
@container (max-width: 640px) {
.stream {
--session-card-width: 96%;
}
}
.messageActions {
display: inline-flex;
@@ -233,6 +258,7 @@
-webkit-user-select: none;
user-select: none;
}
.conversationRow:nth-last-child(1 of [data-message]) .messageActions,
.message:hover .messageActions,
.reply:hover .messageActions,
.agentMessage:hover .messageActions,
@@ -301,10 +327,10 @@
.user {
width: 100%;
box-sizing: border-box;
padding: var(--space-2);
padding: var(--space-2) calc(var(--space-2) + var(--space-2) / 4);
border: 1px solid var(--border);
border-radius: var(--radius-small);
background: var(--bg-elev);
background: var(--bg-tree-selected-inactive);
color: var(--fg-strong);
line-height: 1.5;
white-space: pre-wrap;
@@ -792,7 +818,7 @@
display: flex;
flex-direction: column;
gap: 6px;
align-self: flex-start;
align-self: center;
width: 100%;
max-width: calc(var(--session-content-width) + var(--space-3) * 2);
box-sizing: border-box;
+38 -38
View File
@@ -603,18 +603,21 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
const times = useMemo(() => (entries && !subagent ? replyTimes(entries) : null), [entries, subagent]);
const busy = subagent ? subagentWorking(subagent.status) : isBusy(task.status);
const lastIndex = entries ? entries.length - 1 : -1;
// Keep the row stable when earlier history is prepended; its author can move with the work.
const rows = useMemo(() => (entries ? conversationRows(entries, start) : []), [entries, start]);
const authorHeaders = useMemo(() => {
const headers = new Set<number>();
let first = true;
entries?.slice(start).forEach((entry, i) => {
if (entry.type === "user") first = true;
if (entry.type === "agent" && first) {
headers.add(start + i);
first = false;
}
const byAgent = (row: ConversationRow) =>
row.kind === "process" ||
(row.entry.type !== "user" &&
row.entry.type !== "subagent" &&
!(row.entry.type === "message" && row.entry.incoming));
rows.forEach((row, i) => {
const previous = rows[i - 1];
if (byAgent(row) && (!previous || !byAgent(previous))) headers.add(row.index);
});
return headers;
}, [entries, start]);
}, [rows]);
const renderRow = useCallback(
(row: EntryRow) => (
<Entry
@@ -626,15 +629,12 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
reply={replies?.get(row.index)}
time={times?.get(row.index) ?? null}
working={row.index === lastIndex && busy}
showAuthor={authorHeaders.has(row.index)}
onOpenSubagent={onOpenSubagent}
onDiagramDrawn={onDiagramDrawn}
/>
),
[task, turns, replies, times, lastIndex, busy, authorHeaders, onOpenSubagent, onDiagramDrawn],
[task, turns, replies, times, lastIndex, busy, onOpenSubagent, onDiagramDrawn],
);
// Only the tail is built while following the end; `start` moves earlier as batches load.
const rows = useMemo(() => (entries ? conversationRows(entries, start) : []), [entries, start]);
return (
<div className={styles.streamArea}>
{finding && <FindBar streamRef={ref} focus={findFocus} onClose={onCloseFind} />}
@@ -668,20 +668,30 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
{entries === null ? (
<Spinner />
) : (
rows.map((row) =>
row.kind === "process" ? (
<ProcessRun
key={`process:${row.index}`}
row={row}
task={task}
lastIndex={lastIndex}
busy={busy}
renderRow={renderRow}
/>
) : (
renderRow(row)
),
)
rows.map((row) => (
<div
key={row.index}
className={styles.conversationRow}
data-message={
(row.kind === "entry" && (row.entry.type === "user" || row.entry.type === "agent")) || undefined
}
data-process={row.kind === "process" || row.entry.type !== "user" || undefined}
>
{authorHeaders.has(row.index) && (
<div className={styles.agentAuthor}>
<div className={styles.role}>
<Icon name={providerIcon(task.provider)} size={13} />
<span>{providerName(task.provider)}</span>
</div>
</div>
)}
{row.kind === "process" ? (
<ProcessRun row={row} task={task} lastIndex={lastIndex} busy={busy} renderRow={renderRow} />
) : (
renderRow(row)
)}
</div>
))
)}
{subagent && subagentWorking(subagent.status) && (
<div className={styles.working}>
@@ -964,7 +974,6 @@ const Entry = memo(function Entry({
reply,
time,
working,
showAuthor,
onOpenSubagent,
onDiagramDrawn,
}: {
@@ -977,7 +986,6 @@ const Entry = memo(function Entry({
/** When the turn this reply answered was asked and finished. */
time?: ReplyTimes | null;
working: boolean;
showAuthor: boolean;
onOpenSubagent: (id: string) => void;
onDiagramDrawn: () => void;
}) {
@@ -986,15 +994,7 @@ const Entry = memo(function Entry({
return <UserMessage task={task} entry={entry} turn={turn} turnCount={turnCount} />;
case "agent":
return (
<div className={styles.agentMessage} data-continuation={!showAuthor || undefined}>
{showAuthor && (
<div className={styles.messageHead}>
<div className={styles.role}>
<Icon name={providerIcon(task.provider)} size={13} />
<span>{providerName(task.provider)}</span>
</div>
</div>
)}
<div className={styles.agentMessage}>
<div className={styles.agentCard}>
<AgentReply
text={entry.text}
@@ -1007,8 +1007,8 @@ const Entry = memo(function Entry({
/>
</div>
<div className={styles.messageFooter}>
<ReplyActions text={entry.text} task={task} reply={reply} turnCount={turnCount} />
<MessageTime sentMs={time?.sentMs ?? null} doneMs={time?.doneMs ?? null} at="done" />
<ReplyActions text={entry.text} task={task} reply={reply} turnCount={turnCount} />
</div>
</div>
);
+70 -2
View File
@@ -121,7 +121,7 @@ beforeEach(() => {
});
describe("conversation rows", () => {
it("groups agent authors by turn and uses matching icon-only message action rows", async () => {
it("keeps authors above messages and their folded work with matching icon-only actions", async () => {
const user = (text: string): AgentEntry => ({
type: "user",
text,
@@ -136,12 +136,17 @@ describe("conversation rows", () => {
tool("read"),
{ type: "agent", text: "More detail" },
user("Next question"),
{ type: "thought", text: "Considering the next question" },
tool("next"),
{ type: "agent", text: "Next reply" },
]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
await screen.findByText("More detail");
expect(stream().querySelectorAll(`.${styles.agentMessage} .${styles.role}`)).toHaveLength(2);
expect(stream().querySelectorAll(`.${styles.agentAuthor} .${styles.role}`)).toHaveLength(2);
const author = stream().querySelectorAll(`.${styles.agentAuthor}`)[1]!;
expect(author.nextElementSibling).toHaveClass(styles.processRun!);
expect(author.parentElement?.nextElementSibling?.firstElementChild).toHaveClass(styles.agentMessage!);
const quote = within(stream()).getAllByRole("button", { name: "Quote Reply" });
const copy = within(stream()).getAllByRole("button", { name: "Copy Markdown" });
expect(quote).toHaveLength(3);
@@ -155,6 +160,69 @@ describe("conversation rows", () => {
expect(within(stream()).getAllByRole("button", { name: "Copy" })[0]?.className).toBe(copy[0]?.className);
});
it.each([null, "allow"])("keeps tool, reasoning, plan and approval rows in one author run (%s)", async (chosen) => {
backend.entries.set("A", [
{ type: "agent", text: "I’ll read package.json…" },
tool("one"),
tool("two"),
{ type: "agent", text: "More detail" },
tool("List .", { status: "running" }),
{ type: "permission", id: 1, title: "Run python3", options: [], chosen },
{ type: "plan", items: [{ content: "Check files", status: "in_progress" }] },
{ type: "thought", text: "Consider the result" },
{ type: "agent", text: "Done" },
]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
await screen.findByText("Done");
expect(stream().querySelectorAll(`.${styles.agentAuthor}`)).toHaveLength(1);
expect(stream().firstElementChild?.firstElementChild).toHaveClass(styles.agentAuthor!);
});
it.each<AgentEntry>([subagent, { type: "message", incoming: true, text: "Parent request" }])(
"starts another author run after $type entries",
async (other) => {
backend.entries.set("A", [
{ type: "agent", text: "Before the switch" },
other,
{ type: "thought", text: "After the switch" },
tool("read"),
{ type: "agent", text: "After the work" },
]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
await screen.findByText("After the work");
const authors = stream().querySelectorAll(`.${styles.agentAuthor}`);
expect(authors).toHaveLength(2);
expect(stream().children[1]?.querySelector(`.${styles.agentAuthor}`)).toBeNull();
expect(authors[1]?.nextElementSibling).toHaveClass(styles.processRun!);
expect(authors[1]?.parentElement?.nextElementSibling?.firstElementChild).toHaveClass(styles.agentMessage!);
},
);
it("anchors every agent footer with its time before the hover actions", async () => {
backend.entries.set("A", [
{ type: "user", text: "Question", attachments: [], steered: false, sentMs: 1000, doneMs: 13000 },
{ type: "agent", text: "First reply" },
tool("read"),
{ type: "agent", text: "Final reply" },
]);
useAgents.setState({ entries: new Map(backend.entries) });
render(<SessionEditor input={input("A")} />);
await screen.findByText("Final reply");
const messages = stream().querySelectorAll(`.${styles.agentMessage}`);
expect(messages).toHaveLength(2);
for (const message of messages) {
const [card, footer] = message.children;
expect(card).toHaveClass(styles.agentCard!);
expect(footer).toHaveClass(styles.messageFooter!);
expect(footer?.firstElementChild).toHaveClass(styles.messageTime!);
expect(footer?.firstElementChild).toHaveTextContent("12s");
expect(footer?.lastElementChild).toHaveClass(styles.messageActions!);
}
expect(stream().querySelector(`.${styles.userMessage} > .${styles.messageFooter}`)).not.toBeNull();
});
it("builds only the requested tail, with absolute indexes", () => {
const entries = Array.from({ length: 45 }, (_, i) => ({ type: "agent", text: `reply ${i}` }) as AgentEntry);
const rows = conversationRows(entries, 40);