дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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
Цей коміт міститься в:
1 джерело
06a7aedb66
коміт
6d37f0876c
5 files changed
+199
-78
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Заблокувати користувача