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:
authored and GitHub committed 2026-11-08 06:57:09 +08:00
1 parent 04b1b863df
commit 826e5837b1
17 files changed
+1042 -234

No files matched your search

+13 -11
View File
@@ -165,7 +165,7 @@ test("copies reply Markdown with the keyboard and keeps code copying separate",
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
const actions = copy.locator("..");
await page.mouse.move(0, 0);
await expect(actions).toHaveCSS("opacity", "0");
await expect(actions).toHaveCSS("opacity", "1");
await copy.focus();
await expect(actions).toHaveCSS("opacity", "1");
await page.keyboard.press("Enter");
@@ -175,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
View File
@@ -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);
});
+62 -42
View File
@@ -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>
);
}
+198 -29
View File
@@ -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;
+400 -128
View File
@@ -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) && (
+69 -1
View File
@@ -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();
+17
View File
@@ -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>();
+45 -1
View File
@@ -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",
+165 -1
View File
@@ -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 =
+37 -12
View File
@@ -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");
+7 -1
View File
@@ -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."
}
-1
View File
@@ -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.",
-1
View File
@@ -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.",
-1
View File
@@ -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 모양과 동작을 사용자 지정합니다. 테마와 글꼴 크기를 변경하고 시스템 설정과 알림을 구성합니다.",
-1
View File
@@ -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.",
-1
View File
@@ -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.",
+7 -2
View File
@@ -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.": "智能体仍在停止中。请在停止后重试。"
}