mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
feat(agents): chat-style conversation view with folded tool runs, tags, jump-to-latest and retract (#2067)
* feat(agents): chat-style conversation view with folded tool runs, tags, jump-to-latest and retract (#1980) - Lay the conversation out as chat: user messages on the right, agent replies on the left, each with an author row, a card, actions in the header and a time/duration footer. - Fold consecutive reasoning and finished tool calls into one collapsible 'Reasoning - N tool calls' row with +added / -removed counts; running tools stay expanded. - Render @file and /skill references in message text as distinct tags. - Load earlier entries in batches without moving the viewport, offer 'Jump to Latest' when scrolled away, and defer loading while the scrollbar is dragged. - Add 'Retract Message': stop the agent, drop that turn and every later one, and put the original text/images back in the composer. - Drop the catalog entry for the replaced duration tooltip. * fix(agents): preserve conversation scrolling and retract state --------- Co-authored-by: SunZhiQiang <[email protected]> Co-authored-by: l0ng-ai <[email protected]>
This commit is contained in:
17 files changed
+1042
-234
No files matched your search
+13
-11
@@ -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,21 +175,21 @@ test("copies reply Markdown with the keyboard and keeps code copying separate",
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("const a = 1;");
|
||||
});
|
||||
|
||||
test("reveals message actions on hover without shifting content and quotes into the draft", async ({ page }) => {
|
||||
test("keeps message actions visible without shifting content and quotes into the draft", 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 expect(userCopy.locator("..")).toHaveCSS("opacity", "1");
|
||||
await page.getByText("Show Markdown", { exact: true }).hover();
|
||||
await expect(userCopy.locator("..")).toHaveCSS("opacity", "1");
|
||||
await reply.hover();
|
||||
@@ -249,17 +249,19 @@ test("keeps chat history readable and selections free of timestamps and status l
|
||||
replyLeft: rr.left,
|
||||
bubbleRight: br.right,
|
||||
replyRight: rr.right,
|
||||
columnCenter: (rr.left + rr.right) / 2,
|
||||
columnCenter: (() => {
|
||||
const column = reply.closest("[class*='_agentMessage_']")!.getBoundingClientRect();
|
||||
return (column.left + column.right) / 2;
|
||||
})(),
|
||||
composerCenter: (cr.left + cr.right) / 2,
|
||||
};
|
||||
});
|
||||
expect(geometry.reply).toBe(900);
|
||||
expect(geometry.reply).toBeGreaterThan(0);
|
||||
expect(geometry.reply).toBeLessThan(geometry.root);
|
||||
expect(Math.abs(geometry.bubbleRight - geometry.replyRight)).toBeLessThan(1);
|
||||
expect(Math.abs(geometry.bubbleLeft - geometry.replyLeft)).toBeLessThan(1);
|
||||
expect(geometry.bubbleLeft).toBeGreaterThan(geometry.replyRight);
|
||||
expect(Math.abs(geometry.columnCenter - geometry.composerCenter)).toBeLessThan(1);
|
||||
await stream.getByText("hi", { exact: true }).hover();
|
||||
await expect(stream.locator("[class*='_messageMeta_']").first()).toHaveCSS("opacity", "1");
|
||||
await expect(stream.locator("[class*='_messageTime_']").first()).toBeVisible();
|
||||
const selected = await stream.evaluate((root) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(root);
|
||||
@@ -292,7 +294,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(Math.abs(narrow.reply - (narrow.width - narrow.padding))).toBeLessThan(1);
|
||||
expect(narrow.reply).toBeLessThanOrEqual((narrow.width - narrow.padding) * 0.8);
|
||||
});
|
||||
|
||||
test("keeps hover actions reachable when the pointer leaves the message body", async ({ page, context }) => {
|
||||
|
||||
+22
-1
@@ -61,7 +61,7 @@ async function openHistory(page: Page) {
|
||||
{ task, entries },
|
||||
);
|
||||
const stream = page.locator("[class*='_stream_']:visible");
|
||||
await expect(stream.locator(":scope > *")).toHaveCount(entries.length);
|
||||
await expect(stream.locator(":scope > *")).toHaveCount(80 * 3);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
return stream;
|
||||
}
|
||||
@@ -111,3 +111,24 @@ for (const selected of [false, true]) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("opens a folded process with the keyboard and jumps back to the latest reply", async ({ page }) => {
|
||||
const stream = await openHistory(page);
|
||||
await stream.evaluate((el) => {
|
||||
el.scrollTop = 0;
|
||||
});
|
||||
const jump = page.getByRole("button", { name: "Jump to Latest" });
|
||||
await expect(jump).toBeVisible();
|
||||
const fold = stream.locator("[class*='_processRun_']").first();
|
||||
const summary = fold.locator(":scope > summary");
|
||||
await summary.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(fold).toHaveAttribute("open", "");
|
||||
await expect(fold.getByText("read src/module-0.ts", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(fold).not.toHaveAttribute("open", "");
|
||||
await jump.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(jump).toBeHidden();
|
||||
await expect.poll(() => stream.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThan(40);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export function AgentReply({
|
||||
reply,
|
||||
turnCount = 0,
|
||||
onDiagramDrawn,
|
||||
actions = true,
|
||||
}: {
|
||||
text: string;
|
||||
cwd: string;
|
||||
@@ -21,6 +22,32 @@ export function AgentReply({
|
||||
reply?: ReplyTurn;
|
||||
turnCount?: number;
|
||||
onDiagramDrawn?: () => void;
|
||||
/** False when the caller shows `ReplyActions` in the message's header row. */
|
||||
actions?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.reply}>
|
||||
<Markdown text={text} cwd={cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
{actions && text.trim() && (
|
||||
<div className={styles.replyFooter}>
|
||||
<ReplyActions text={text} task={task} reply={reply} turnCount={turnCount} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Copy, quote, regenerate and branch for a reply; kept in its header row when it has one. */
|
||||
export function ReplyActions({
|
||||
text,
|
||||
task,
|
||||
reply,
|
||||
turnCount = 0,
|
||||
}: {
|
||||
text: string;
|
||||
task?: AgentTask;
|
||||
reply?: ReplyTurn;
|
||||
turnCount?: number;
|
||||
}) {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const operations = task ? replyOperations(task, reply, turnCount) : { regenerate: false, fork: false };
|
||||
@@ -30,48 +57,41 @@ export function AgentReply({
|
||||
return () => clearTimeout(timer);
|
||||
}, [copied]);
|
||||
return (
|
||||
<div className={styles.reply}>
|
||||
<Markdown text={text} cwd={cwd} onDiagramDrawn={onDiagramDrawn} />
|
||||
{text.trim() && (
|
||||
<div className={styles.replyFooter}>
|
||||
<span className={clsx(styles.messageActions, styles.replyActions)} data-copied={copied === text || undefined}>
|
||||
<IconButton
|
||||
icon={copied === text ? "check" : "copy"}
|
||||
title={copied === text ? "Copied" : "Copy Markdown"}
|
||||
size="small"
|
||||
action={copyReplyAction.id}
|
||||
actionArg={{ text, onCopied: () => setCopied(text) } satisfies CopyReply}
|
||||
/>
|
||||
{task && task.status !== "archived" && (
|
||||
<IconButton
|
||||
icon="quote"
|
||||
title="Quote Reply"
|
||||
size="small"
|
||||
action="agents.quoteReply"
|
||||
actionArg={{ task: task.id, text }}
|
||||
/>
|
||||
)}
|
||||
{task && reply && operations.regenerate && (
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
title="Regenerate Response"
|
||||
size="small"
|
||||
action="agents.regenerateReply"
|
||||
actionArg={{ task: task.id, turn: reply.turn }}
|
||||
/>
|
||||
)}
|
||||
{task && reply && operations.fork && (
|
||||
<IconButton
|
||||
icon="branch"
|
||||
title="Branch Conversation from Here"
|
||||
size="small"
|
||||
action="agents.forkReply"
|
||||
actionArg={{ task: task.id, turn: reply.turn }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className={clsx(styles.messageActions, styles.replyActions)} data-copied={copied === text || undefined}>
|
||||
<IconButton
|
||||
icon={copied === text ? "check" : "copy"}
|
||||
title={copied === text ? "Copied" : "Copy Markdown"}
|
||||
size="normal"
|
||||
action={copyReplyAction.id}
|
||||
actionArg={{ text, onCopied: () => setCopied(text) } satisfies CopyReply}
|
||||
/>
|
||||
{task && task.status !== "archived" && (
|
||||
<IconButton
|
||||
icon="quote"
|
||||
title="Quote Reply"
|
||||
size="normal"
|
||||
action="agents.quoteReply"
|
||||
actionArg={{ task: task.id, text }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{task && reply && operations.regenerate && (
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
title="Regenerate Response"
|
||||
size="normal"
|
||||
action="agents.regenerateReply"
|
||||
actionArg={{ task: task.id, turn: reply.turn }}
|
||||
/>
|
||||
)}
|
||||
{task && reply && operations.fork && (
|
||||
<IconButton
|
||||
icon="branch"
|
||||
title="Branch Conversation from Here"
|
||||
size="normal"
|
||||
action="agents.forkReply"
|
||||
actionArg={{ task: task.id, turn: reply.turn }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -54,23 +54,30 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.headerDetail {
|
||||
.header > .headerSpacer {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.providerIcon {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
/* The task's state, just above the composer; the header keeps only the actions. */
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 0 2px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.headerText {
|
||||
.statusText {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.providerIcon {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.muted {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
@@ -142,10 +149,10 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overflow-anchor: none;
|
||||
padding: calc(var(--space-3) * 2) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--space-3) * 2);
|
||||
}
|
||||
/* Actual row heights keep WebKit's scrollbar and cross-message selections anchored. */
|
||||
.stream > * {
|
||||
@@ -155,9 +162,45 @@
|
||||
margin-inline: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* Messages keep room around them; the folded reasoning/tool rows sit close together. */
|
||||
.stream > * + * {
|
||||
margin-top: calc(var(--space-3) * 2);
|
||||
}
|
||||
.stream > * + .process {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.stream > .process + * {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.stream > .process + .process {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.stream:focus {
|
||||
outline: none;
|
||||
}
|
||||
/* Scrolled up: one click back to the latest message, floating over the stream's end. */
|
||||
.jumpToLatest {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: var(--space-3);
|
||||
transform: translateX(-50%);
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-popup);
|
||||
border-radius: 50%;
|
||||
background: var(--bg-popup);
|
||||
color: var(--fg);
|
||||
box-shadow: var(--shadow-popup);
|
||||
cursor: pointer;
|
||||
}
|
||||
.jumpToLatest:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
/* Your message with its actions, shown on hover. */
|
||||
.message {
|
||||
position: relative;
|
||||
@@ -165,35 +208,101 @@
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.messageMeta {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
/* The author line, with the reply actions on its right; its height is always reserved. */
|
||||
.messageHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
max-width: 100%;
|
||||
padding-top: 2px;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
min-height: 24px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.messageTime {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
}
|
||||
.messageHead > .role {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* Chat layout: the user's messages sit on the right, the agent's on the left. */
|
||||
.userMessage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.userMessage > .messageHead {
|
||||
align-self: stretch;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.userMessage > .user {
|
||||
width: fit-content;
|
||||
max-width: min(80%, 720px);
|
||||
}
|
||||
.userMessage > .messageFooter {
|
||||
align-self: flex-end;
|
||||
}
|
||||
/* The actions and time below a message, in flow so they never cover the next entry. */
|
||||
.messageFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 24px;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* The footer's icons stay visible: the time follows them, at the message's side. */
|
||||
.messageFooter .messageActions {
|
||||
margin-left: 0;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* Who wrote the message, above its card. */
|
||||
.role {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-bottom: 3px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.agentMessage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.agentMessage > .messageHead,
|
||||
.agentCard {
|
||||
max-width: min(80%, 760px);
|
||||
}
|
||||
.agentCard {
|
||||
box-sizing: border-box;
|
||||
width: fit-content;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-popup);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.messageActions {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
margin-left: 6px;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.message:hover .messageActions,
|
||||
.message:hover .messageMeta,
|
||||
.reply:hover .messageActions,
|
||||
.agentMessage:hover .messageActions,
|
||||
.agentMessage:focus-within .messageActions,
|
||||
.message:focus-within .messageActions,
|
||||
.message:focus-within .messageMeta,
|
||||
.reply:focus-within .messageActions,
|
||||
.messageActions[data-copied],
|
||||
.messageActions:focus-within {
|
||||
@@ -208,8 +317,9 @@
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
min-height: 20px;
|
||||
min-height: 24px;
|
||||
padding-top: 2px;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
@@ -223,7 +333,6 @@
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.messageMeta,
|
||||
.replyFooter {
|
||||
position: static;
|
||||
margin-top: 4px;
|
||||
@@ -261,8 +370,9 @@
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border));
|
||||
border-radius: var(--radius-popup);
|
||||
background: var(--bg-elev);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-app));
|
||||
color: var(--fg-strong);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
@@ -535,6 +645,51 @@
|
||||
.toolDiff:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Reasoning and tool calls between messages, folded as one block per turn. */
|
||||
.processRun > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.processRun > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.processSummary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 2px 8px;
|
||||
margin-left: -8px;
|
||||
border-radius: var(--radius-row);
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.processSummary:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.processChevron {
|
||||
flex: none;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
.processRun[open] .processChevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.processRun:not([open]) > .processBody {
|
||||
display: none;
|
||||
}
|
||||
.processBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 2px;
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.output {
|
||||
margin: 4px 0 0;
|
||||
max-height: 240px;
|
||||
@@ -568,12 +723,25 @@
|
||||
}
|
||||
|
||||
.permissionLine {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-popup);
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
/* Waiting for an answer: the card takes the warning colour of the banner above. */
|
||||
.permissionAsking {
|
||||
border-color: var(--border-feedback-warning);
|
||||
background: var(--bg-feedback-warning);
|
||||
}
|
||||
.permissionDone {
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* Title, state and answers on one line when they fit; a long title takes its own line and
|
||||
the state and answers go below it, in line with it. */
|
||||
.permissionBody {
|
||||
@@ -588,6 +756,8 @@
|
||||
.permissionTitle {
|
||||
display: -webkit-box;
|
||||
flex: 0 1 auto;
|
||||
color: var(--fg-strong);
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -674,7 +844,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
min-height: 28px;
|
||||
padding: 0 4px 0 8px;
|
||||
border-radius: var(--radius-row);
|
||||
background: var(--bg-elev);
|
||||
@@ -765,9 +935,8 @@
|
||||
.attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
gap: 4px 6px;
|
||||
margin-top: 4px;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.attachment {
|
||||
@@ -1047,8 +1216,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
height: var(--h-control);
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-small);
|
||||
background: transparent;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
InlineTag,
|
||||
Radio,
|
||||
SearchField,
|
||||
Spinner,
|
||||
@@ -52,11 +54,20 @@ import { isImeKey } from "@/platform/ime";
|
||||
import { TaskStatusIcon } from "./AgentsView";
|
||||
import { Composer } from "./Composer";
|
||||
import { SubmitButton } from "./SubmitButton";
|
||||
import { canSend, costLabel, emptyDraft, limitLabel, retryParts, usageLabel, workTime } from "./composerModel";
|
||||
import {
|
||||
canSend,
|
||||
costLabel,
|
||||
emptyDraft,
|
||||
limitLabel,
|
||||
messageTokens,
|
||||
retryParts,
|
||||
usageLabel,
|
||||
workTime,
|
||||
} from "./composerModel";
|
||||
import { ConfigPickers } from "./ConfigPickers";
|
||||
import { findRanges, formatDuration, formatSentAt } from "./conversation";
|
||||
import { ImagePreview, imageSrc } from "./ImagePreview";
|
||||
import { AgentReply } from "./AgentReply";
|
||||
import { AgentReply, ReplyActions } from "./AgentReply";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { useAgentErrorText } from "./loginHint";
|
||||
import {
|
||||
@@ -71,6 +82,8 @@ import {
|
||||
subagentWorking,
|
||||
turnNumbers,
|
||||
replyTurns,
|
||||
replyTimes,
|
||||
type ReplyTimes,
|
||||
type ReplyTurn,
|
||||
type SubagentEntry,
|
||||
} from "./model";
|
||||
@@ -163,8 +176,6 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
<div className={styles.conversation} hidden={docked && changesOpen}>
|
||||
<Header
|
||||
task={task}
|
||||
entries={entries ?? null}
|
||||
head={head}
|
||||
changes={changes ?? null}
|
||||
changesOpen={changesOpen}
|
||||
onToggleChanges={() => setChangesOpen(!changesOpen)}
|
||||
@@ -189,7 +200,7 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
) : (
|
||||
<>
|
||||
<QuestionCard task={task} entries={entries ?? null} />
|
||||
<SessionComposer task={task} entries={entries ?? null} onOpenSubagent={openSubagent} />
|
||||
<SessionComposer task={task} entries={entries ?? null} head={head} onOpenSubagent={openSubagent} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -237,27 +248,20 @@ function useLocalHead(status: AgentTask["status"] | null, root: string | null):
|
||||
|
||||
function Header({
|
||||
task,
|
||||
entries,
|
||||
head,
|
||||
changes,
|
||||
changesOpen,
|
||||
onToggleChanges,
|
||||
docked,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
head: string | null;
|
||||
changes: AgentChange[] | null;
|
||||
changesOpen: boolean;
|
||||
onToggleChanges: () => void;
|
||||
/** The tool window bar above a docked conversation already shows the title. */
|
||||
docked?: boolean;
|
||||
}) {
|
||||
const project = useProject((s) => s.project);
|
||||
const projectName = project?.workspace ? basename(task.projectRoot ?? project.root) : null;
|
||||
const tx = useTx();
|
||||
const count = changes?.length ?? 0;
|
||||
const busy = isBusy(task.status);
|
||||
const where = task.isolation === "worktree" ? branchLabel(task.branch) : null;
|
||||
return (
|
||||
<div
|
||||
className={styles.header}
|
||||
@@ -272,40 +276,14 @@ function Header({
|
||||
{task.title}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.headerDetail} title={task.cwd}>
|
||||
<Icon name={providerIcon(task.provider)} size={13} className={styles.providerIcon} />
|
||||
<span className={styles.headerText}>
|
||||
{providerName(task.provider)}
|
||||
{task.model ? ` · ${task.model}` : ""}
|
||||
{projectName ? ` · ${projectName}` : ""}
|
||||
{" · "}
|
||||
{where ?? (
|
||||
<>
|
||||
<T>Local checkout</T>
|
||||
{head ? ` · ${head}` : ""}
|
||||
</>
|
||||
)}
|
||||
{" · "}
|
||||
<T>{statusLabel(task.status)}</T>
|
||||
{task.status === "limited" && task.resetsMs != null && ` · ${tx`resets ${formatSentAt(task.resetsMs)}`}`}
|
||||
</span>
|
||||
</span>
|
||||
<Meters task={task} entries={entries} />
|
||||
{busy && <IconButton icon="stop" title="Stop" size="small" onClick={() => void cancelTask(task.id)} />}
|
||||
<span className={styles.headerSpacer} />
|
||||
<IconButton
|
||||
icon="terminal"
|
||||
title="Open in Terminal"
|
||||
size="small"
|
||||
onClick={() => void executeAction("terminal.openAt", task.cwd)}
|
||||
/>
|
||||
{task.status === "archived" && (
|
||||
<IconButton
|
||||
icon="undo"
|
||||
title="Unarchive Task"
|
||||
size="small"
|
||||
action="agents.unarchiveTasks"
|
||||
actionArg={[task.id]}
|
||||
/>
|
||||
<IconButton icon="undo" title="Unarchive Task" action="agents.unarchiveTasks" actionArg={[task.id]} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -323,6 +301,43 @@ function Header({
|
||||
);
|
||||
}
|
||||
|
||||
/** The task's agent, model, checkout and state, just above the composer. */
|
||||
function SessionStatus({
|
||||
task,
|
||||
entries,
|
||||
head,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
head: string | null;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const project = useProject((s) => s.project);
|
||||
const projectName = project?.workspace ? basename(task.projectRoot ?? project.root) : null;
|
||||
const where = task.isolation === "worktree" ? branchLabel(task.branch) : null;
|
||||
return (
|
||||
<div className={styles.statusBar}>
|
||||
<Icon name={providerIcon(task.provider)} size={13} className={styles.providerIcon} />
|
||||
<span className={styles.statusText} title={task.cwd}>
|
||||
{providerName(task.provider)}
|
||||
{task.model ? ` · ${task.model}` : ""}
|
||||
{projectName ? ` · ${projectName}` : ""}
|
||||
{" · "}
|
||||
{where ?? (
|
||||
<>
|
||||
<T>Local checkout</T>
|
||||
{head ? ` · ${head}` : ""}
|
||||
</>
|
||||
)}
|
||||
{" · "}
|
||||
<T>{statusLabel(task.status)}</T>
|
||||
{task.status === "limited" && task.resetsMs != null && ` · ${tx`resets ${formatSentAt(task.resetsMs)}`}`}
|
||||
</span>
|
||||
<Meters task={task} entries={entries} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Context use, usage limits and cost (when the agent reports them) and the time spent on
|
||||
* the task, each as Settings › Tools › Agents › Chat allows.
|
||||
@@ -401,7 +416,7 @@ function SubagentBar({
|
||||
const card = cards.at(-1);
|
||||
return (
|
||||
<div className={styles.subagentBar}>
|
||||
<IconButton icon="arrow-left" title="Back" size="small" onClick={() => onNavigate(path.slice(0, -1))} />
|
||||
<IconButton icon="arrow-left" title="Back" onClick={() => onNavigate(path.slice(0, -1))} />
|
||||
<nav className={styles.crumbs} aria-label={tx("Subagent")}>
|
||||
<button type="button" className={styles.crumb} onClick={() => onNavigate([])} title={task.title}>
|
||||
{task.title}
|
||||
@@ -430,7 +445,7 @@ function SubagentBar({
|
||||
</span>
|
||||
)}
|
||||
{card && subagentWorking(card.status) && card.cancellable && (
|
||||
<IconButton icon="stop" title="Stop Subagent" size="small" onClick={() => void cancelTask(task.id, card.id)} />
|
||||
<IconButton icon="stop" title="Stop Subagent" onClick={() => void cancelTask(task.id, card.id)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -458,12 +473,64 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
const positions = useAgents.getState().scrollPositions;
|
||||
const position = useRef(positions.get(key) ?? { top: 0, stick: true });
|
||||
const restored = useRef(false);
|
||||
const previousLength = useRef(entries?.length ?? 0);
|
||||
/** Scrolled away from the end: offer one click back to the latest message. */
|
||||
const [away, setAway] = useState(() => positions.get(key)?.stick === false);
|
||||
// Opened at the end, the last entries come first and the earlier ones follow in batches:
|
||||
// reopening a long conversation paints at once. A kept offset needs them all.
|
||||
const [shown, setShown] = useState(() => (positions.get(key)?.stick === false ? Infinity : ENTRY_BATCH));
|
||||
const start = finding || !entries ? 0 : Math.max(0, entries.length - shown);
|
||||
let start = finding || !entries ? 0 : Math.max(0, entries.length - shown);
|
||||
// Keep a process run intact so prepending never replaces the anchored row.
|
||||
while (entries && start > 0 && isProcessEntry(entries[start]!) && isProcessEntry(entries[start - 1]!)) start--;
|
||||
const dragging = useRef(false);
|
||||
// Earlier entries are prepended above the view: remember where the first painted row is
|
||||
// so the content stays put and the scrollbar doesn't jump under the pointer.
|
||||
const anchor = useRef<{ el: Element; top: number } | null>(null);
|
||||
const loadEarlier = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el || start <= 0 || dragging.current || anchor.current) return;
|
||||
const first = el.firstElementChild;
|
||||
if (!position.current.stick && first) anchor.current = { el: first, top: first.getBoundingClientRect().top };
|
||||
startTransition(() => setShown((n) => n + ENTRY_BATCH));
|
||||
}, [start]);
|
||||
const loadTimer = useRef<number | null>(null);
|
||||
/** Load the next batch once the user stops scrolling near the top. */
|
||||
const loadWhenStill = useCallback(() => {
|
||||
if (loadTimer.current !== null) window.clearTimeout(loadTimer.current);
|
||||
loadTimer.current = window.setTimeout(() => {
|
||||
loadTimer.current = null;
|
||||
loadEarlier();
|
||||
}, 120);
|
||||
}, [loadEarlier]);
|
||||
useEffect(() => {
|
||||
if (start > 0) startTransition(() => setShown((n) => n + ENTRY_BATCH));
|
||||
const down = (event: PointerEvent) => {
|
||||
if (!ref.current?.contains(event.target as Node)) return;
|
||||
dragging.current = true;
|
||||
if (loadTimer.current !== null) window.clearTimeout(loadTimer.current);
|
||||
};
|
||||
const up = () => {
|
||||
if (!dragging.current) return;
|
||||
dragging.current = false;
|
||||
if (ref.current && (position.current.stick || ref.current.scrollTop < 120)) loadWhenStill();
|
||||
};
|
||||
window.addEventListener("pointerdown", down, true);
|
||||
window.addEventListener("pointerup", up, true);
|
||||
window.addEventListener("pointercancel", up, true);
|
||||
window.addEventListener("blur", up);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", down, true);
|
||||
window.removeEventListener("pointerup", up, true);
|
||||
window.removeEventListener("pointercancel", up, true);
|
||||
window.removeEventListener("blur", up);
|
||||
if (loadTimer.current !== null) window.clearTimeout(loadTimer.current);
|
||||
};
|
||||
}, [loadWhenStill]);
|
||||
useEffect(() => {
|
||||
// Following the end, paint the rest in the background. A user who scrolled away
|
||||
// gets them only after stopping near the top (`loadWhenStill`), so the scrollbar
|
||||
// is not resized mid-drag.
|
||||
if (start > 0 && position.current.stick && !dragging.current)
|
||||
startTransition(() => setShown((n) => n + ENTRY_BATCH));
|
||||
}, [start]);
|
||||
// Hidden behind another tab, it has no layout to scroll; shown again, it follows the end again.
|
||||
const visible = useEditorShown();
|
||||
@@ -472,6 +539,17 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
const el = ref.current;
|
||||
if (el && position.current.stick) el.scrollTop = el.scrollHeight;
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || entries === null || !visible) return;
|
||||
const truncated = entries.length < previousLength.current;
|
||||
previousLength.current = entries.length;
|
||||
if ((truncated || entries.length === 0) && el.scrollHeight - el.scrollTop - el.clientHeight < 40) {
|
||||
position.current = { top: el.scrollTop, stick: true };
|
||||
positions.set(key, position.current);
|
||||
setAway(false);
|
||||
}
|
||||
}, [entries, visible, key, positions]);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || entries === null || !visible) return;
|
||||
@@ -479,6 +557,16 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
else if (!restored.current) el.scrollTop = position.current.top;
|
||||
restored.current = true;
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
// Prepending earlier entries moved the first painted row down: follow it, so the same
|
||||
// text stays on screen. Rows appended below leave it in place and shift nothing.
|
||||
const scroller = ref.current;
|
||||
const from = anchor.current;
|
||||
if (!scroller || !from) return;
|
||||
anchor.current = null;
|
||||
if (position.current.stick || !from.el.isConnected) return;
|
||||
scroller.scrollTop += from.el.getBoundingClientRect().top - from.top;
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
@@ -491,11 +579,43 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
if (restored.current) positions.set(key, { ...position.current, top: el.scrollTop });
|
||||
};
|
||||
}, [key, positions]);
|
||||
/** Back to the end of the stream; following resumes from there. */
|
||||
const jumpToLatest = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
position.current = { top: el.scrollHeight, stick: true };
|
||||
if (typeof el.scrollTo === "function") el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
else el.scrollTop = el.scrollHeight;
|
||||
setAway(false);
|
||||
el.focus({ preventScroll: true });
|
||||
}, []);
|
||||
const waiting = entries?.some((e) => e.type === "permission" && !e.chosen);
|
||||
const lastUser = entries?.findLast((e): e is UserEntry => e.type === "user");
|
||||
// Forks and undo apply to the task's own turns, not a subagent's conversation.
|
||||
const turns = useMemo(() => (entries && !subagent ? turnNumbers(entries) : null), [entries, subagent]);
|
||||
const replies = useMemo(() => (entries && !subagent ? replyTurns(entries) : null), [entries, subagent]);
|
||||
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;
|
||||
const renderRow = useCallback(
|
||||
(row: EntryRow) => (
|
||||
<Entry
|
||||
key={row.index}
|
||||
task={task}
|
||||
entry={row.entry}
|
||||
turn={turns?.get(row.index) ?? null}
|
||||
turnCount={turns?.size ?? 0}
|
||||
reply={replies?.get(row.index)}
|
||||
time={times?.get(row.index) ?? null}
|
||||
working={row.index === lastIndex && busy}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onDiagramDrawn={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} />}
|
||||
@@ -508,6 +628,11 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
const el = e.currentTarget;
|
||||
position.current = { top: el.scrollTop, stick: el.scrollHeight - el.scrollTop - el.clientHeight < 40 };
|
||||
positions.set(key, position.current);
|
||||
setAway(!position.current.stick);
|
||||
// Reached the top of what is painted: fetch the earlier entries, but only
|
||||
// once the drag has settled.
|
||||
if (loadTimer.current !== null) window.clearTimeout(loadTimer.current);
|
||||
if (!position.current.stick && el.scrollTop < 120 && !dragging.current) loadWhenStill();
|
||||
}}
|
||||
// Copy, plus Quote for a selection: replies to it in the composer.
|
||||
onContextMenu={(e) => {
|
||||
@@ -524,24 +649,20 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
{entries === null ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
entries
|
||||
.slice(start)
|
||||
.map((entry, i) => (
|
||||
<Entry
|
||||
key={start + i}
|
||||
rows.map((row) =>
|
||||
row.kind === "process" ? (
|
||||
<ProcessRun
|
||||
key={`process:${row.index}`}
|
||||
row={row}
|
||||
task={task}
|
||||
entry={entry}
|
||||
turn={turns?.get(start + i) ?? null}
|
||||
turnCount={turns?.size ?? 0}
|
||||
reply={replies?.get(start + i)}
|
||||
working={
|
||||
start + i === entries.length - 1 &&
|
||||
(subagent ? subagentWorking(subagent.status) : isBusy(task.status))
|
||||
}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onDiagramDrawn={onDiagramDrawn}
|
||||
lastIndex={lastIndex}
|
||||
busy={busy}
|
||||
renderRow={renderRow}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
renderRow(row)
|
||||
),
|
||||
)
|
||||
)}
|
||||
{subagent && subagentWorking(subagent.status) && (
|
||||
<div className={styles.working}>
|
||||
@@ -562,13 +683,64 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{away && !!entries?.length && <JumpToLatest onJump={jumpToLatest} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type UserEntry = Extract<AgentEntry, { type: "user" }>;
|
||||
|
||||
/** Time since `since`, ticking every second. */
|
||||
/** Message text with file references and skills as tags, distinct from prose. */
|
||||
function MessageText({ text }: { text: string }) {
|
||||
return (
|
||||
<>
|
||||
{messageTokens(text).map((token, i) =>
|
||||
token.kind === "text" ? (
|
||||
<Fragment key={i}>{token.value}</Fragment>
|
||||
) : (
|
||||
<InlineTag key={i} accent={token.kind === "command"}>
|
||||
{token.value}
|
||||
</InlineTag>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** When a message was sent, or when the turn behind a reply finished, and how long it took. */
|
||||
function MessageTime({
|
||||
sentMs,
|
||||
doneMs,
|
||||
at = "sent",
|
||||
}: {
|
||||
sentMs: number | null;
|
||||
doneMs: number | null;
|
||||
/** Replies show when they finished; messages show when they were sent. */
|
||||
at?: "sent" | "done";
|
||||
}) {
|
||||
const shown = at === "done" ? doneMs : sentMs;
|
||||
if (shown == null) return null;
|
||||
return (
|
||||
<span className={styles.messageTime} title={new Date(shown).toLocaleString()}>
|
||||
{formatSentAt(shown)}
|
||||
{sentMs != null && doneMs != null && ` · ${formatDuration(Math.max(0, doneMs - sentMs))}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One click back to the stream's end. */
|
||||
function JumpToLatest({ onJump }: { onJump: () => void }) {
|
||||
return (
|
||||
<IconButton
|
||||
className={styles.jumpToLatest}
|
||||
title="Jump to Latest"
|
||||
icon="arrow-down"
|
||||
action="agents.jumpToLatest"
|
||||
actionArg={{ onJump }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The agent lost its API connection (or was refused) and retries on its own. */
|
||||
function Retrying({ retry }: { retry: AgentRetry }) {
|
||||
const tx = useTx();
|
||||
@@ -771,6 +943,7 @@ const Entry = memo(function Entry({
|
||||
turn,
|
||||
turnCount,
|
||||
reply,
|
||||
time,
|
||||
working,
|
||||
onOpenSubagent,
|
||||
onDiagramDrawn,
|
||||
@@ -781,6 +954,8 @@ const Entry = memo(function Entry({
|
||||
turn: number | null;
|
||||
turnCount: number;
|
||||
reply?: ReplyTurn;
|
||||
/** When the turn this reply answered was asked and finished. */
|
||||
time?: ReplyTimes | null;
|
||||
working: boolean;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onDiagramDrawn: () => void;
|
||||
@@ -790,18 +965,33 @@ const Entry = memo(function Entry({
|
||||
return <UserMessage task={task} entry={entry} turn={turn} turnCount={turnCount} />;
|
||||
case "agent":
|
||||
return (
|
||||
<AgentReply
|
||||
text={entry.text}
|
||||
cwd={task.cwd}
|
||||
task={task}
|
||||
reply={reply}
|
||||
turnCount={turnCount}
|
||||
onDiagramDrawn={onDiagramDrawn}
|
||||
/>
|
||||
<div className={styles.agentMessage}>
|
||||
<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.agentCard}>
|
||||
<AgentReply
|
||||
text={entry.text}
|
||||
cwd={task.cwd}
|
||||
task={task}
|
||||
reply={reply}
|
||||
turnCount={turnCount}
|
||||
onDiagramDrawn={onDiagramDrawn}
|
||||
actions={false}
|
||||
/>
|
||||
</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" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case "thought":
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.process}>
|
||||
<details className={styles.thought}>
|
||||
<summary>
|
||||
{working ? (
|
||||
@@ -851,7 +1041,9 @@ const Entry = memo(function Entry({
|
||||
<div className={styles.messageFrom}>
|
||||
<T>Task from the parent agent</T>
|
||||
</div>
|
||||
<div className={styles.user}>{entry.text}</div>
|
||||
<div className={styles.user}>
|
||||
<MessageText text={entry.text} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
@@ -888,7 +1080,6 @@ function UserMessage({
|
||||
turn: number | null;
|
||||
turnCount: number;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const failed = (title: string) => (e: unknown) => notifications().notify({ level: "error", title, body: String(e) });
|
||||
const fail = failed("Couldn't send the message");
|
||||
@@ -916,9 +1107,14 @@ function UserMessage({
|
||||
// Images are listed after the files in `attachments` and shown as thumbnails instead.
|
||||
const files = entry.attachments.slice(0, entry.attachments.length - images.length);
|
||||
return (
|
||||
<div className={styles.message}>
|
||||
<div className={clsx(styles.message, styles.userMessage)}>
|
||||
<div className={styles.messageHead}>
|
||||
<span className={styles.role}>
|
||||
<T>You</T>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.user}>
|
||||
{entry.text}
|
||||
<MessageText text={entry.text} />
|
||||
<ImageStrip images={images} />
|
||||
{files.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
@@ -931,27 +1127,22 @@ function UserMessage({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.messageMeta}>
|
||||
{entry.sentMs != null && (
|
||||
<span className={styles.muted} title={new Date(entry.sentMs).toLocaleString()}>
|
||||
{formatSentAt(entry.sentMs)}
|
||||
</span>
|
||||
)}
|
||||
{entry.sentMs != null && entry.doneMs != null && (
|
||||
<span className={styles.muted} title={tx("Time the agent took to answer")}>
|
||||
{` · ${formatDuration(entry.doneMs - entry.sentMs)}`}
|
||||
</span>
|
||||
)}
|
||||
<div className={styles.messageFooter}>
|
||||
<span className={styles.messageActions}>
|
||||
<IconButton icon="copy" title="Copy" size="small" onClick={() => void copyText(entry.text)} />
|
||||
{inPlace && entry.text && (
|
||||
<IconButton icon="edit" title="Edit" size="small" onClick={() => setEditing(true)} />
|
||||
<IconButton icon="copy" title="Copy" onClick={() => void copyText(entry.text)} />
|
||||
{inPlace && entry.text && <IconButton icon="edit" title="Edit" onClick={() => setEditing(true)} />}
|
||||
{inPlace && (
|
||||
<IconButton
|
||||
icon="diff-revert"
|
||||
title="Retract Message"
|
||||
action="agents.retractMessage"
|
||||
actionArg={{ task: task.id, turn }}
|
||||
/>
|
||||
)}
|
||||
{!archived && entry.text && entry.attachments.length === 0 && (
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
title={inPlace ? "Run Again From Here" : "Send Again"}
|
||||
size="small"
|
||||
onClick={() =>
|
||||
void (
|
||||
inPlace ? rerunFrom(task.id, turn, entry.text, images) : resendMessage(task.id, entry.text)
|
||||
@@ -963,11 +1154,11 @@ function UserMessage({
|
||||
<IconButton
|
||||
icon="branch"
|
||||
title="Copy to a New Task From Here"
|
||||
size="small"
|
||||
onClick={() => void forkTask(task.id, turn + 1).catch(failed("Couldn't copy the task"))}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<MessageTime sentMs={entry.sentMs ?? null} doneMs={entry.doneMs ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1048,9 +1239,7 @@ function MessageEditor({
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.editButtons}>
|
||||
<Button size="small" disabled={sending} onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<IconButton icon="close" title="Cancel" disabled={sending} onClick={onCancel} />
|
||||
<SubmitButton
|
||||
label="Send"
|
||||
pending={sending}
|
||||
@@ -1097,7 +1286,103 @@ function ImageStrip({ images, onRemove }: { images: AgentImage[]; onRemove?: (in
|
||||
);
|
||||
}
|
||||
|
||||
function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract<AgentEntry, { type: "tool" }> }) {
|
||||
type ToolEntryData = Extract<AgentEntry, { type: "tool" }>;
|
||||
|
||||
/** An entry and its index in the whole conversation (not in the painted tail). */
|
||||
type IndexedEntry = { index: number; entry: AgentEntry };
|
||||
|
||||
/** A row of the conversation: an entry, or a run of reasoning/tool calls folded together. */
|
||||
type ConversationRow =
|
||||
| { kind: "entry"; index: number; entry: AgentEntry }
|
||||
| { kind: "process"; index: number; entries: IndexedEntry[] };
|
||||
|
||||
type EntryRow = Extract<ConversationRow, { kind: "entry" }>;
|
||||
|
||||
const liveTool = (entry: AgentEntry) =>
|
||||
entry.type === "tool" && (entry.status === "running" || entry.status === "pending");
|
||||
|
||||
/** Reasoning and finished tool calls fold together; a running tool stays visible on its own. */
|
||||
const isProcessEntry = (entry: AgentEntry) => entry.type === "thought" || (entry.type === "tool" && !liveTool(entry));
|
||||
|
||||
/**
|
||||
* The conversation's rows from `from`: a run of reasoning and finished tool calls becomes
|
||||
* one folded row (the work behind a decision), so messages and decisions stay readable.
|
||||
*/
|
||||
export function conversationRows(entries: readonly AgentEntry[], from: number): ConversationRow[] {
|
||||
const rows: ConversationRow[] = [];
|
||||
let run: IndexedEntry[] = [];
|
||||
const flush = () => {
|
||||
if (run.length > 1) rows.push({ kind: "process", index: run[0]!.index, entries: run });
|
||||
else if (run.length === 1) rows.push({ kind: "entry", ...run[0]! });
|
||||
run = [];
|
||||
};
|
||||
entries.slice(from).forEach((entry, i) => {
|
||||
if (isProcessEntry(entry)) run.push({ index: from + i, entry });
|
||||
else {
|
||||
flush();
|
||||
rows.push({ kind: "entry", index: from + i, entry });
|
||||
}
|
||||
});
|
||||
flush();
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Reasoning and tool calls between messages, folded into one row. */
|
||||
const ProcessRun = memo(function ProcessRun({
|
||||
row,
|
||||
task,
|
||||
lastIndex,
|
||||
busy,
|
||||
renderRow,
|
||||
}: {
|
||||
row: Extract<ConversationRow, { kind: "process" }>;
|
||||
task: AgentTask;
|
||||
lastIndex: number;
|
||||
busy: boolean;
|
||||
/** Renders one non-folded row (a reasoning entry). */
|
||||
renderRow: (row: EntryRow) => ReactElement;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
const tools = row.entries.filter(
|
||||
(item): item is IndexedEntry & { entry: ToolEntryData } => item.entry.type === "tool",
|
||||
);
|
||||
const reasoning = row.entries.filter(({ entry }) => entry.type === "thought").length;
|
||||
const added = tools.reduce((n, { entry }) => n + entry.diffs.reduce((sum, d) => sum + d.added, 0), 0);
|
||||
const removed = tools.reduce((n, { entry }) => n + entry.diffs.reduce((sum, d) => sum + d.removed, 0), 0);
|
||||
const working = busy && row.entries.some(({ index }) => index === lastIndex);
|
||||
const parts = [
|
||||
reasoning > 0 ? tx("Reasoning") : null,
|
||||
tools.length > 0 ? `${tools.length} ${tx(tools.length === 1 ? "tool call" : "tool calls")}` : null,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<details className={clsx(styles.processRun, styles.process)}>
|
||||
<summary className={styles.processSummary}>
|
||||
<Icon name="chevron-right" size={12} className={styles.processChevron} />
|
||||
{working && <Spinner size={12} />}
|
||||
<span>{parts.join(" · ")}</span>
|
||||
{added > 0 && <span className={styles.added}>+{added}</span>}
|
||||
{removed > 0 && <span className={styles.removed}>−{removed}</span>}
|
||||
</summary>
|
||||
<div className={styles.processBody}>
|
||||
{row.entries.map(({ index, entry }) =>
|
||||
entry.type === "tool" ? (
|
||||
<ToolEntry key={index} task={task} entry={entry} />
|
||||
) : (
|
||||
renderRow({ kind: "entry", index, entry })
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
});
|
||||
|
||||
const ToolEntry = memo(function ToolEntry({
|
||||
task,
|
||||
entry,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
entry: Extract<AgentEntry, { type: "tool" }>;
|
||||
}) {
|
||||
const head = (
|
||||
<>
|
||||
<Icon name={TOOL_ICONS[entry.kind] ?? "run"} size={14} />
|
||||
@@ -1122,19 +1407,19 @@ function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract<AgentEntry
|
||||
);
|
||||
if (!entry.output)
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.process}>
|
||||
<div className={styles.tool}>{head}</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<details className={styles.toolDetails}>
|
||||
<div className={styles.process}>
|
||||
<details className={styles.toolDetails} open={liveTool(entry) || undefined}>
|
||||
<summary className={styles.tool}>{head}</summary>
|
||||
<pre className={styles.output}>{entry.output}</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function SubagentStatusIcon({ status }: { status: SubagentEntry["status"] }) {
|
||||
switch (status) {
|
||||
@@ -1290,9 +1575,7 @@ function PermissionAnswers({
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<button type="button" className={styles.bannerLink} disabled={busy} onClick={() => answer(null)}>
|
||||
<T>Cancel</T>
|
||||
</button>
|
||||
<IconButton icon="close" title="Cancel" disabled={busy} onClick={() => answer(null)} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
@@ -1301,7 +1584,7 @@ function PermissionAnswers({
|
||||
/** A permission request in the conversation; while open, answered right there as in the banner. */
|
||||
function PermissionLine({ task, entry }: { task: AgentTask; entry: PermissionEntry }) {
|
||||
return (
|
||||
<div className={styles.permissionLine}>
|
||||
<div className={clsx(styles.permissionLine, entry.chosen ? styles.permissionDone : styles.permissionAsking)}>
|
||||
<Icon name={!entry.chosen ? "warning" : entry.chosen === "Cancelled" ? "minus" : "check"} size={13} />
|
||||
<div className={styles.permissionBody}>
|
||||
<span className={styles.permissionTitle} title={shortTitle(task, entry.title)}>
|
||||
@@ -1573,10 +1856,12 @@ function QuestionForm({
|
||||
function SessionComposer({
|
||||
task,
|
||||
entries,
|
||||
head,
|
||||
onOpenSubagent,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
entries: AgentEntry[] | null;
|
||||
head: string | null;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
}) {
|
||||
const project = useProject((s) => s.project);
|
||||
@@ -1604,14 +1889,17 @@ function SessionComposer({
|
||||
);
|
||||
return (
|
||||
<div className={styles.composerArea}>
|
||||
<SessionStatus task={task} entries={entries} head={head} />
|
||||
{task.status === "archived" && (
|
||||
<div className={styles.banner}>
|
||||
<span className={styles.bannerText}>
|
||||
<T>This task is archived. Unarchive it to continue the conversation.</T>
|
||||
</span>
|
||||
<Button size="small" onClick={() => void executeAction("agents.unarchiveTasks", [task.id])}>
|
||||
Unarchive
|
||||
</Button>
|
||||
<IconButton
|
||||
icon="undo"
|
||||
title="Unarchive"
|
||||
onClick={() => void executeAction("agents.unarchiveTasks", [task.id])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<WorkingSubagents entries={entries} onOpen={onOpenSubagent} />
|
||||
@@ -1631,7 +1919,7 @@ function SessionComposer({
|
||||
toolbar={<ConfigPickers config={task.config} onChange={setOption} />}
|
||||
actions={
|
||||
<>
|
||||
{busy && <IconButton icon="stop" title="Stop" size="small" onClick={() => void cancelTask(task.id)} />}
|
||||
{busy && <IconButton icon="stop" title="Stop" onClick={() => void cancelTask(task.id)} />}
|
||||
<SubmitButton
|
||||
label={busy ? "Queue" : "Send"}
|
||||
pending={sending}
|
||||
@@ -1690,25 +1978,9 @@ function Queue({ task }: { task: AgentTask }) {
|
||||
<span className={styles.queuedText} title={q.prompt.text}>
|
||||
{q.prompt.text || <T>Image</T>}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.bannerLink}
|
||||
onClick={() => void sendQueuedNow(task.id, q.id).catch(fail)}
|
||||
>
|
||||
<T>Send now</T>
|
||||
</button>
|
||||
<IconButton
|
||||
icon="edit"
|
||||
title="Edit"
|
||||
size="small"
|
||||
onClick={() => void editQueued(task.id, q.id).catch(fail)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="close"
|
||||
title="Remove"
|
||||
size="small"
|
||||
onClick={() => void removeQueued(task.id, q.id).catch(fail)}
|
||||
/>
|
||||
<IconButton icon="arrow-up" title="Send now" onClick={() => void sendQueuedNow(task.id, q.id).catch(fail)} />
|
||||
<IconButton icon="edit" title="Edit" onClick={() => void editQueued(task.id, q.id).catch(fail)} />
|
||||
<IconButton icon="close" title="Remove" onClick={() => void removeQueued(task.id, q.id).catch(fail)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1782,7 +2054,7 @@ function ChangesPane({
|
||||
return (
|
||||
<aside className={styles.changes} aria-label={tx("Changes")}>
|
||||
<div className={styles.changesHeader}>
|
||||
{docked && <IconButton icon="arrow-left" title="Back to conversation" size="small" onClick={onHide} />}
|
||||
{docked && <IconButton icon="arrow-left" title="Back to conversation" onClick={onHide} />}
|
||||
<span className={styles.changesTitle}>
|
||||
<T>Changes</T>
|
||||
</span>
|
||||
@@ -1792,7 +2064,7 @@ function ChangesPane({
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.bannerSpacer} />
|
||||
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshChanges(task.id)} />
|
||||
<IconButton icon="refresh" title="Refresh" onClick={() => void refreshChanges(task.id)} />
|
||||
{!docked && <IconButton icon="chevron-right" title={tx("Hide changes")} size="small" onClick={onHide} />}
|
||||
</div>
|
||||
{(head ?? task.base) && (
|
||||
|
||||
@@ -26,7 +26,8 @@ vi.mock("@tauri-apps/api/webview", () => ({
|
||||
}));
|
||||
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
import { composerDraft, rerunFrom, sendMessage, updateComposer, useAgents } from "./store";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { composerDraft, retractFrom, rerunFrom, sendMessage, updateComposer, useAgents } from "./store";
|
||||
import { replyActions } from "./replyActions";
|
||||
|
||||
const task: AgentTask = {
|
||||
@@ -50,7 +51,12 @@ const task: AgentTask = {
|
||||
capabilities: { fork: true, rollback: true, steer: true, images: true },
|
||||
};
|
||||
|
||||
let returnDispose: () => void;
|
||||
afterEach(() => returnDispose());
|
||||
|
||||
beforeEach(() => {
|
||||
const dispose = registerActions(replyActions);
|
||||
returnDispose = dispose;
|
||||
ipc.confirm.mockReset().mockResolvedValue(true);
|
||||
ipc.rollback.mockReset().mockResolvedValue({ status: "ok", data: null });
|
||||
ipc.prompt.mockReset().mockResolvedValue({ status: "ok", data: null });
|
||||
@@ -76,6 +82,68 @@ it("keeps the transcript and running session when a fresh-session rerun is cance
|
||||
expect(useAgents.getState().entries.get(task.id)).toEqual(entries);
|
||||
});
|
||||
|
||||
describe("retracting a turn", () => {
|
||||
it.each([0, 1])("stops before rollback and restores text/images without sending (turn %s)", async (turn) => {
|
||||
const image = { mimeType: "image/png", data: "AAA", name: "shot.png" };
|
||||
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
|
||||
updateComposer(task.id, { text: "Draft" });
|
||||
const running = retractFrom(task.id, turn, "Original", [image]);
|
||||
await waitFor(() => expect(ipc.cancel).toHaveBeenCalledTimes(1));
|
||||
expect(ipc.rollback).not.toHaveBeenCalled();
|
||||
useAgents.setState({ tasks: new Map([[task.id, task]]) });
|
||||
await running;
|
||||
expect(ipc.rollback).toHaveBeenCalledExactlyOnceWith(task.id, turn);
|
||||
expect(ipc.prompt).not.toHaveBeenCalled();
|
||||
expect(composerDraft(task.id)).toEqual({ text: "Original\nDraft", images: [image], files: [] });
|
||||
expect(useAgents.getState().focus?.key).toBe(task.id);
|
||||
});
|
||||
|
||||
it("keeps the draft and avoids rollback when the agent does not stop in time", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
useAgents.setState({ tasks: new Map([[task.id, { ...task, status: "running" }]]) });
|
||||
const result = expect(retractFrom(task.id, 1, "Original")).rejects.toThrow("The agent is still stopping");
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
await result;
|
||||
expect(ipc.rollback).not.toHaveBeenCalled();
|
||||
expect(composerDraft(task.id).text).toBe("Original");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers the original prompt when rollback fails", async () => {
|
||||
ipc.rollback.mockResolvedValue({ status: "error", error: { kind: "internal", message: "Rollback failed" } });
|
||||
await expect(retractFrom(task.id, 1, "Original")).rejects.toThrow("Rollback failed");
|
||||
expect(composerDraft(task.id).text).toBe("Original");
|
||||
expect(ipc.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("confirms before retracting a turn with later messages and guards repeated clicks", async () => {
|
||||
const transcript = [...entries, { ...entries[0]!, text: "Follow up" } as AgentEntry, entries[1]!];
|
||||
useAgents.setState({ entries: new Map([[task.id, transcript]]) });
|
||||
let answer!: (ok: boolean) => void;
|
||||
ipc.confirm.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
);
|
||||
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
|
||||
const user = userEvent.setup();
|
||||
const retract = screen.getAllByRole("button", { name: "Retract Message" })[0]!;
|
||||
await user.click(retract);
|
||||
await user.click(retract);
|
||||
expect(ipc.confirm).toHaveBeenCalledTimes(1);
|
||||
expect(ipc.rollback).not.toHaveBeenCalled();
|
||||
await act(async () => answer(false));
|
||||
expect(useAgents.getState().entries.get(task.id)).toEqual(transcript);
|
||||
ipc.confirm.mockResolvedValue(true);
|
||||
await user.click(retract);
|
||||
await waitFor(() => expect(ipc.rollback).toHaveBeenCalledExactlyOnceWith(task.id, 0));
|
||||
expect(composerDraft(task.id).text).toBe("Original message");
|
||||
});
|
||||
});
|
||||
|
||||
async function edit() {
|
||||
render(<SessionEditor input={{ kind: "agent-session", key: task.id, data: { task: task.id } }} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -132,6 +132,23 @@ export interface ReplyTurn {
|
||||
steered: boolean;
|
||||
}
|
||||
|
||||
export interface ReplyTimes {
|
||||
sentMs: number | null;
|
||||
doneMs: number | null;
|
||||
}
|
||||
|
||||
/** When the turn behind each agent reply was asked and finished, by entry index. */
|
||||
export function replyTimes(entries: AgentEntry[]): Map<number, ReplyTimes> {
|
||||
const out = new Map<number, ReplyTimes>();
|
||||
let current: Extract<AgentEntry, { type: "user" }> | null = null;
|
||||
entries.forEach((entry, i) => {
|
||||
if (entry.type === "user" && !entry.steered) current = entry;
|
||||
else if (entry.type === "agent" && current)
|
||||
out.set(i, { sentMs: current.sentMs ?? null, doneMs: current.doneMs ?? null });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Only the last reply of a turn offers actions that change the conversation. */
|
||||
export function replyTurns(entries: AgentEntry[]): Map<number, ReplyTurn> {
|
||||
const out = new Map<number, ReplyTurn>();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Action } from "@/platform/actions";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { replyOperations, replyTurns, turnNumbers } from "./model";
|
||||
import { forkTask, quoteInComposer, rerunFrom, useAgents } from "./store";
|
||||
import { forkTask, quoteInComposer, rerunFrom, retractFrom, useAgents } from "./store";
|
||||
|
||||
export interface CopyReply {
|
||||
text: string;
|
||||
@@ -57,6 +59,48 @@ async function runReplyOperation(task: string, title: string, run: () => Promise
|
||||
|
||||
export const replyActions: Action[] = [
|
||||
copyReplyAction,
|
||||
{
|
||||
id: "agents.jumpToLatest",
|
||||
title: "Jump to Latest",
|
||||
category: "Agents",
|
||||
icon: "arrow-down",
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
const target = arg as { onJump?: () => void } | undefined;
|
||||
target?.onJump?.();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agents.retractMessage",
|
||||
title: "Retract Message",
|
||||
category: "Agents",
|
||||
icon: "diff-revert",
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
const target = arg as ReplyTarget | undefined;
|
||||
if (!target || typeof target.task !== "string" || !Number.isInteger(target.turn)) return;
|
||||
const task = useAgents.getState().tasks.get(target.task);
|
||||
const entries = useAgents.getState().entries.get(target.task);
|
||||
if (!task || !entries || task.status === "archived" || !task.capabilities?.rollback) return;
|
||||
const turns = turnNumbers(entries);
|
||||
const index = [...turns].find(([, turn]) => turn === target.turn)?.[0];
|
||||
const prompt = index === undefined ? undefined : entries[index];
|
||||
if (prompt?.type !== "user") return;
|
||||
return runReplyOperation(task.id, "Couldn't retract the message", async () => {
|
||||
if (
|
||||
target.turn < turns.size - 1 &&
|
||||
!(await confirm({
|
||||
title: "Retract Message",
|
||||
message: tx("The messages after this one are replaced. Files the agent changed stay as they are."),
|
||||
ok: "Retract",
|
||||
icon: "warning",
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await retractFrom(task.id, target.turn, prompt.text, prompt.images ?? []);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agents.quoteReply",
|
||||
title: "Quote Reply",
|
||||
|
||||
@@ -5,11 +5,13 @@ import { useProject } from "@/platform/project";
|
||||
import { EditorShownProvider } from "@/workbench/contributions";
|
||||
import type { AgentChange, AgentEntry, AgentTask } from "@/bindings";
|
||||
import { AgentsView } from "./AgentsView";
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
import { SessionEditor, conversationRows } from "./SessionEditor";
|
||||
import { entriesKey, SESSION_KIND, sessionKey, type SubagentEntry } from "./model";
|
||||
import { disconnect, useAgents } from "./store";
|
||||
import { taskActions } from "./taskActions";
|
||||
import { replyActions } from "./replyActions";
|
||||
import styles from "./Session.module.css";
|
||||
import tagStyles from "@/ui/InlineTag.module.css";
|
||||
|
||||
const backend = vi.hoisted(() => ({
|
||||
entries: new Map<string, AgentEntry[]>(),
|
||||
@@ -78,15 +80,29 @@ const subagent: SubagentEntry = {
|
||||
};
|
||||
const input = (id: string) => ({ kind: SESSION_KIND, key: sessionKey(id), data: { task: id } });
|
||||
const stream = () => document.querySelector<HTMLDivElement>(`.${styles.stream}`)!;
|
||||
const tool = (id: string, overrides: Partial<Extract<AgentEntry, { type: "tool" }>> = {}): AgentEntry => ({
|
||||
type: "tool",
|
||||
id,
|
||||
title: id,
|
||||
kind: "read",
|
||||
status: "completed",
|
||||
locations: [],
|
||||
output: null,
|
||||
diffs: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
let disposeActions: () => void;
|
||||
const scrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTop");
|
||||
afterEach(() => {
|
||||
disposeActions();
|
||||
vi.restoreAllMocks();
|
||||
if (scrollTopDescriptor) Object.defineProperty(HTMLElement.prototype, "scrollTop", scrollTopDescriptor);
|
||||
else Reflect.deleteProperty(HTMLElement.prototype, "scrollTop");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
disposeActions = registerActions(replyActions);
|
||||
disconnect();
|
||||
localStorage.clear();
|
||||
backend.changes = [];
|
||||
@@ -104,6 +120,154 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation rows", () => {
|
||||
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);
|
||||
expect(rows).toHaveLength(5);
|
||||
expect(rows.map((row) => row.index)).toEqual([40, 41, 42, 43, 44]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("process folding", () => {
|
||||
it("folds reasoning and tool calls into one block", async () => {
|
||||
backend.entries.set("A", [
|
||||
{ type: "thought", text: "think one" },
|
||||
tool("one"),
|
||||
{ type: "thought", text: "think two" },
|
||||
tool("two"),
|
||||
{ type: "agent", text: "Answer A" },
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const summary = await screen.findByText("Reasoning · 2 tool calls");
|
||||
expect(summary.closest("details")!.open).toBe(false);
|
||||
expect(screen.getByText("think one")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("message tags", () => {
|
||||
it("shows file references and skills as distinct tags", async () => {
|
||||
backend.entries.set("A", [
|
||||
{
|
||||
type: "user",
|
||||
text: "fix @src/a.ts and run /triage",
|
||||
attachments: ["a.ts"],
|
||||
images: [],
|
||||
sentMs: 1,
|
||||
doneMs: 2,
|
||||
steered: false,
|
||||
},
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
expect(await screen.findByText("@src/a.ts")).toHaveClass(tagStyles.tag!);
|
||||
expect(screen.getByText("/triage")).toHaveClass(tagStyles.accent!);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scrolling", () => {
|
||||
it("hides Jump to Latest and follows new replies after retracting the conversation", async () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const el = stream();
|
||||
let height = 1000;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, get: () => height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
el.scrollTop = 100;
|
||||
fireEvent.scroll(el);
|
||||
expect(screen.getByRole("button", { name: "Jump to Latest" })).toBeInTheDocument();
|
||||
height = 400;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", []]]) }));
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
height = 1400;
|
||||
act(() => useAgents.setState({ entries: new Map([["A", [{ type: "agent", text: "New reply" }]]]) }));
|
||||
expect(el.scrollTop).toBe(1400);
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
});
|
||||
|
||||
it("defers prepending during a pointer drag and preserves the first row's viewport position", async () => {
|
||||
const entries = Array.from({ length: 90 }, (_, i): AgentEntry => ({ type: "agent", text: `History ${i}` }));
|
||||
useAgents.setState({ entries: new Map([["A", entries]]) });
|
||||
// Mount hidden so the initial batch hasn't yet restored the bottom offset.
|
||||
const view = render(
|
||||
<EditorShownProvider value={false}>
|
||||
<SessionEditor input={input("A")} />
|
||||
</EditorShownProvider>,
|
||||
);
|
||||
const el = stream();
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 3000 });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
// Give the conversation a new tail while the scrollbar is held.
|
||||
fireEvent.pointerDown(el);
|
||||
act(() =>
|
||||
useAgents.setState({
|
||||
entries: new Map([["A", [...entries, ...entries.map((e, i) => ({ ...e, text: `New ${i}` }))]]]),
|
||||
}),
|
||||
);
|
||||
el.scrollTop = 20;
|
||||
fireEvent.scroll(el);
|
||||
const count = el.children.length;
|
||||
const first = el.firstElementChild!;
|
||||
const top = vi.spyOn(first, "getBoundingClientRect");
|
||||
top.mockReturnValue({ top: 100 } as DOMRect);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
});
|
||||
expect(el.children.length).toBe(count);
|
||||
// Its old top is measured at release, then shifts down when rows are inserted.
|
||||
top.mockReturnValueOnce({ top: 100 } as DOMRect).mockReturnValue({ top: 500 } as DOMRect);
|
||||
fireEvent.pointerUp(window);
|
||||
await waitFor(() => expect(el.children.length).toBeGreaterThan(count));
|
||||
expect(el.scrollTop).toBe(420);
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
it("offers one click back to the latest message after scrolling up", async () => {
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
expect(await screen.findByText("Answer A")).toBeInTheDocument();
|
||||
const el = stream();
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1000 });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 400 });
|
||||
el.scrollTop = 100;
|
||||
fireEvent.scroll(el);
|
||||
const jump = await screen.findByRole("button", { name: "Jump to Latest" });
|
||||
Object.defineProperty(el, "scrollTo", { configurable: true, value: vi.fn() });
|
||||
fireEvent.click(jump);
|
||||
expect(el.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" });
|
||||
expect(screen.queryByRole("button", { name: "Jump to Latest" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool calls", () => {
|
||||
it.each(["pending", "running"] as const)(
|
||||
"keeps %s tools outside folds with their output expanded",
|
||||
async (status) => {
|
||||
backend.entries.set("A", [
|
||||
tool("done one"),
|
||||
tool("done two"),
|
||||
tool("live", { status, output: "Reading files…" }),
|
||||
]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const output = await screen.findByText("Reading files…");
|
||||
expect(output.closest("details")!.open).toBe(true);
|
||||
expect(output.closest(`.${styles.processRun}`)).toBeNull();
|
||||
expect(screen.getByText("2 tool calls").closest("details")!.open).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("folds consecutive finished tool calls into one collapsed row", async () => {
|
||||
const edit = (id: string) => tool(id, { kind: "edit", diffs: [{ path: "/project/a.ts", added: 2, removed: 1 }] });
|
||||
backend.entries.set("A", [edit("one"), edit("two"), { type: "agent", text: "Answer A" }]);
|
||||
useAgents.setState({ entries: new Map(backend.entries) });
|
||||
render(<SessionEditor input={input("A")} />);
|
||||
const summary = await screen.findByText("2 tool calls");
|
||||
expect(summary.closest("details")!.open).toBe(false);
|
||||
expect(screen.getByText("+4")).toBeInTheDocument();
|
||||
expect(screen.getByText("−2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pending question", () => {
|
||||
it("shows the whole question in the banner, with every answer, and sends the chosen one", async () => {
|
||||
const question =
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useProject } from "@/platform/project";
|
||||
import { defaultTaskProject } from "./project";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { editorService, notifications, notifyTabTitlesChanged, toolWindowService } from "@/workbench/contributions";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { bypassDefaults, canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel";
|
||||
import { withQuote } from "./conversation";
|
||||
@@ -601,9 +602,29 @@ export async function forkTask(task: string, turns: number | null): Promise<void
|
||||
openTask(copy.id);
|
||||
}
|
||||
|
||||
/** Puts a message back in the task's composer, above anything typed meanwhile. */
|
||||
function restoreComposer(task: string, text: string, images: AgentImage[]) {
|
||||
const current = composerDraft(task);
|
||||
updateComposer(task, {
|
||||
text: current.text ? `${text}\n${current.text}` : text,
|
||||
images: [...images, ...current.images],
|
||||
});
|
||||
focusComposer(task);
|
||||
}
|
||||
|
||||
/** Waits until a running turn has stopped, so it can be dropped. */
|
||||
async function stopForEdit(task: string) {
|
||||
const busy = () => isBusy(useAgents.getState().tasks.get(task)?.status ?? "ready");
|
||||
if (!busy()) return;
|
||||
await cancelTask(task);
|
||||
await settled(task);
|
||||
if (busy()) throw new Error(tx("The agent is still stopping. Try again once it has stopped."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the conversation again from turn `turn` with `text` as its message: the agent is
|
||||
* stopped if it works, that turn and the later ones are dropped, then `text` is sent.
|
||||
* Whatever fails, the message is kept in the composer so it can be sent again.
|
||||
*/
|
||||
export async function rerunFrom(task: string, turn: number, text: string, images: AgentImage[] = []): Promise<boolean> {
|
||||
if (
|
||||
@@ -617,26 +638,30 @@ export async function rerunFrom(task: string, turn: number, text: string, images
|
||||
}))
|
||||
)
|
||||
return false;
|
||||
if (isBusy(useAgents.getState().tasks.get(task)?.status ?? "ready")) {
|
||||
await cancelTask(task);
|
||||
await settled(task);
|
||||
}
|
||||
await call(commands.agentRollback(task, turn));
|
||||
try {
|
||||
await stopForEdit(task);
|
||||
await call(commands.agentRollback(task, turn));
|
||||
await call(commands.agentPrompt(task, { text, files: [], images }, false));
|
||||
} catch (e) {
|
||||
// Rollback removes the original turn; recover its prompt in the session composer.
|
||||
const current = composerDraft(task);
|
||||
updateComposer(task, {
|
||||
text: current.text ? `${text}\n${current.text}` : text,
|
||||
images: [...images, ...current.images],
|
||||
});
|
||||
focusComposer(task);
|
||||
restoreComposer(task, text, images);
|
||||
throw e;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Drops `turn` and the later turns, and puts the message back in the composer to edit. */
|
||||
export async function retractFrom(task: string, turn: number, text: string, images: AgentImage[] = []): Promise<void> {
|
||||
try {
|
||||
await stopForEdit(task);
|
||||
const prompt = await call(commands.agentRollback(task, turn));
|
||||
// The agent only hands back the text; the images come from the message itself.
|
||||
restoreComposer(task, prompt?.text ?? text, images);
|
||||
} catch (e) {
|
||||
restoreComposer(task, text, images);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves once the task's turn has ended (or after a while). */
|
||||
function settled(task: string, timeoutMs = 15_000): Promise<void> {
|
||||
const done = () => !isBusy(useAgents.getState().tasks.get(task)?.status ?? "ready");
|
||||
|
||||
@@ -369,5 +369,11 @@
|
||||
"Absolute Path": "Absolute Path",
|
||||
"Nothing to paste: the clipboard history is empty": "Nothing to paste: the clipboard history is empty",
|
||||
"Filter clipboard history": "Filter clipboard history",
|
||||
"Ask": "Ask"
|
||||
"Ask": "Ask",
|
||||
"You": "You",
|
||||
"Jump to Latest": "Jump to Latest",
|
||||
"Retract Message": "Retract Message",
|
||||
"Retract": "Retract",
|
||||
"Couldn't retract the message": "Couldn't retract the message",
|
||||
"The agent is still stopping. Try again once it has stopped.": "The agent is still stopping. Try again once it has stopped."
|
||||
}
|
||||
@@ -4470,7 +4470,6 @@
|
||||
"Find in Conversation": "Buscar en la conversación",
|
||||
"Find in conversation": "Buscar en la conversación",
|
||||
"0 results": "0 resultados",
|
||||
"Time the agent took to answer": "Tiempo que tardó el agente en responder",
|
||||
"Time the agent spent on this task": "Tiempo que dedicó el agente a esta tarea",
|
||||
"Send Again": "Enviar de nuevo",
|
||||
"Customize IDE appearance and behavior: change themes and font size, configure system settings and notifications.": "Personalice la apariencia y el comportamiento del IDE: cambie temas y tamaño de fuente, configure ajustes del sistema y notificaciones.",
|
||||
|
||||
@@ -4319,7 +4319,6 @@
|
||||
"Find in Conversation": "Rechercher dans la conversation",
|
||||
"Find in conversation": "Rechercher dans la conversation",
|
||||
"0 results": "0 résultat",
|
||||
"Time the agent took to answer": "Temps de réponse de l'agent",
|
||||
"Time the agent spent on this task": "Temps passé par l'agent sur cette tâche",
|
||||
"Send Again": "Renvoyer",
|
||||
"Customize IDE appearance and behavior: change themes and font size, configure system settings and notifications.": "Personnalisez l'apparence et le comportement de l'IDE : thèmes, taille de police, paramètres système et notifications.",
|
||||
|
||||
@@ -4543,7 +4543,6 @@
|
||||
"Find in Conversation": "대화에서 찾기",
|
||||
"Find in conversation": "대화에서 찾기",
|
||||
"0 results": "결과 0개",
|
||||
"Time the agent took to answer": "에이전트가 응답하는 데 걸린 시간",
|
||||
"Time the agent spent on this task": "에이전트가 이 작업에 소요한 시간",
|
||||
"Send Again": "다시 보내기",
|
||||
"Customize IDE appearance and behavior: change themes and font size, configure system settings and notifications.": "IDE 모양과 동작을 사용자 지정합니다. 테마와 글꼴 크기를 변경하고 시스템 설정과 알림을 구성합니다.",
|
||||
|
||||
@@ -4422,7 +4422,6 @@
|
||||
"Find in Conversation": "Znajdź w rozmowie",
|
||||
"Find in conversation": "Znajdź w rozmowie",
|
||||
"0 results": "0 wyników",
|
||||
"Time the agent took to answer": "Czas odpowiedzi agenta",
|
||||
"Time the agent spent on this task": "Czas spędzony przez agenta nad tym zadaniem",
|
||||
"Send Again": "Wyślij ponownie",
|
||||
"Customize IDE appearance and behavior: change themes and font size, configure system settings and notifications.": "Dostosuj wygląd i zachowanie IDE: zmieniaj motywy i rozmiary czcionek, konfiguruj ustawienia systemowe i powiadomienia.",
|
||||
|
||||
@@ -4435,7 +4435,6 @@
|
||||
"Find in Conversation": "Localizar na conversa",
|
||||
"Find in conversation": "Localizar na conversa",
|
||||
"0 results": "0 resultados",
|
||||
"Time the agent took to answer": "Tempo que o agente levou para responder",
|
||||
"Time the agent spent on this task": "Tempo que o agente gastou nesta tarefa",
|
||||
"Send Again": "Enviar novamente",
|
||||
"Customize IDE appearance and behavior: change themes and font size, configure system settings and notifications.": "Personalize a aparência e o comportamento da IDE: altere temas e tamanho da fonte, configure as opções do sistema e notificações.",
|
||||
|
||||
@@ -4725,7 +4725,6 @@
|
||||
"Find in Conversation": "在对话中查找",
|
||||
"Find in conversation": "在对话中查找",
|
||||
"0 results": "0 个结果",
|
||||
"Time the agent took to answer": "智能体回答所用的时间",
|
||||
"Time the agent spent on this task": "智能体在此任务上花费的时间",
|
||||
"Send Again": "再次发送",
|
||||
"Customize IDE appearance and behavior: change themes and font size, configure system settings and notifications.": "自定义 IDE 的外观和行为:更改主题和字号,配置系统设置和通知。",
|
||||
@@ -6724,5 +6723,11 @@
|
||||
"Copy Path From Source Root": "复制相对于源根的路径",
|
||||
"Copied {text}": "已复制 {text}",
|
||||
"Reference": "引用",
|
||||
"Insert imports on paste": "粘贴时插入导入"
|
||||
"Insert imports on paste": "粘贴时插入导入",
|
||||
"You": "你",
|
||||
"Jump to Latest": "跳转到最新位置",
|
||||
"Retract Message": "撤回消息",
|
||||
"Retract": "撤回",
|
||||
"Couldn't retract the message": "无法撤回消息",
|
||||
"The agent is still stopping. Try again once it has stopped.": "智能体仍在停止中。请在停止后重试。"
|
||||
}
|
||||
Reference in new issue
Block a user