diff --git a/e2e/agentMarkdown.spec.ts b/e2e/agentMarkdown.spec.ts
index e1bd4c9e3..7ebc0f61a 100644
--- a/e2e/agentMarkdown.spec.ts
+++ b/e2e/agentMarkdown.spec.ts
@@ -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 }) => {
diff --git a/e2e/agentScroll.spec.ts b/e2e/agentScroll.spec.ts
index 8b18591de..2c35805f8 100644
--- a/e2e/agentScroll.spec.ts
+++ b/e2e/agentScroll.spec.ts
@@ -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);
+});
diff --git a/src/features/agents/AgentReply.tsx b/src/features/agents/AgentReply.tsx
index 44a87a62d..9a1995c8e 100644
--- a/src/features/agents/AgentReply.tsx
+++ b/src/features/agents/AgentReply.tsx
@@ -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 (
+
+
+ {actions && text.trim() && (
+
+
+
+ )}
+
+ );
+}
+
+/** 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(null);
const operations = task ? replyOperations(task, reply, turnCount) : { regenerate: false, fork: false };
@@ -30,48 +57,41 @@ export function AgentReply({
return () => clearTimeout(timer);
}, [copied]);
return (
-
-
- {text.trim() && (
-
-
- setCopied(text) } satisfies CopyReply}
- />
- {task && task.status !== "archived" && (
-
- )}
- {task && reply && operations.regenerate && (
-
- )}
- {task && reply && operations.fork && (
-
- )}
-
-
+
+ setCopied(text) } satisfies CopyReply}
+ />
+ {task && task.status !== "archived" && (
+
)}
-
+ {task && reply && operations.regenerate && (
+
+ )}
+ {task && reply && operations.fork && (
+
+ )}
+
);
}
diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css
index 01f876af6..e1adbad64 100644
--- a/src/features/agents/Session.module.css
+++ b/src/features/agents/Session.module.css
@@ -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;
diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx
index b550c4b8a..919e338e1 100644
--- a/src/features/agents/SessionEditor.tsx
+++ b/src/features/agents/SessionEditor.tsx
@@ -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 }) {
setChangesOpen(!changesOpen)}
@@ -189,7 +200,7 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
) : (
<>
-
+
>
)}
@@ -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 (
)}
-
-
-
- {providerName(task.provider)}
- {task.model ? ` · ${task.model}` : ""}
- {projectName ? ` · ${projectName}` : ""}
- {" · "}
- {where ?? (
- <>
- Local checkout
- {head ? ` · ${head}` : ""}
- >
- )}
- {" · "}
- {statusLabel(task.status)}
- {task.status === "limited" && task.resetsMs != null && ` · ${tx`resets ${formatSentAt(task.resetsMs)}`}`}
-
-
-
- {busy &&
void cancelTask(task.id)} />}
+
void executeAction("terminal.openAt", task.cwd)}
/>
{task.status === "archived" && (
-
+
)}
s.project);
+ const projectName = project?.workspace ? basename(task.projectRoot ?? project.root) : null;
+ const where = task.isolation === "worktree" ? branchLabel(task.branch) : null;
+ return (
+
+
+
+ {providerName(task.provider)}
+ {task.model ? ` · ${task.model}` : ""}
+ {projectName ? ` · ${projectName}` : ""}
+ {" · "}
+ {where ?? (
+ <>
+ Local checkout
+ {head ? ` · ${head}` : ""}
+ >
+ )}
+ {" · "}
+ {statusLabel(task.status)}
+ {task.status === "limited" && task.resetsMs != null && ` · ${tx`resets ${formatSentAt(task.resetsMs)}`}`}
+
+
+
+ );
+}
+
/**
* 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 (
- onNavigate(path.slice(0, -1))} />
+ onNavigate(path.slice(0, -1))} />
onNavigate([])} title={task.title}>
{task.title}
@@ -430,7 +445,7 @@ function SubagentBar({
)}
{card && subagentWorking(card.status) && card.cancellable && (
- void cancelTask(task.id, card.id)} />
+ void cancelTask(task.id, card.id)} />
)}
);
@@ -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(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) => (
+
+ ),
+ [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 (
{finding &&
}
@@ -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 ? (
) : (
- entries
- .slice(start)
- .map((entry, i) => (
-
+ row.kind === "process" ? (
+
- ))
+ ) : (
+ renderRow(row)
+ ),
+ )
)}
{subagent && subagentWorking(subagent.status) && (
@@ -562,13 +683,64 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
)}
+ {away && !!entries?.length && }
);
}
type UserEntry = Extract;
-/** 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" ? (
+ {token.value}
+ ) : (
+
+ {token.value}
+
+ ),
+ )}
+ >
+ );
+}
+
+/** 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 (
+
+ {formatSentAt(shown)}
+ {sentMs != null && doneMs != null && ` · ${formatDuration(Math.max(0, doneMs - sentMs))}`}
+
+ );
+}
+
+/** One click back to the stream's end. */
+function JumpToLatest({ onJump }: { onJump: () => void }) {
+ return (
+
+ );
+}
+
/** 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 ;
case "agent":
return (
-
+
+
+
+
+ {providerName(task.provider)}
+
+
+
+
+
+
+
+
);
case "thought":
return (
-
+
{working ? (
@@ -851,7 +1041,9 @@ const Entry = memo(function Entry({
Task from the parent agent
- {entry.text}
+
+
+
) : (
@@ -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 (
-
+
+
+
+ You
+
+
- {entry.text}
+
{files.length > 0 && (
@@ -931,27 +1127,22 @@ function UserMessage({
)}
-
- {entry.sentMs != null && (
-
- {formatSentAt(entry.sentMs)}
-
- )}
- {entry.sentMs != null && entry.doneMs != null && (
-
- {` · ${formatDuration(entry.doneMs - entry.sentMs)}`}
-
- )}
+
- void copyText(entry.text)} />
- {inPlace && entry.text && (
- setEditing(true)} />
+ void copyText(entry.text)} />
+ {inPlace && entry.text && setEditing(true)} />}
+ {inPlace && (
+
)}
{!archived && entry.text && entry.attachments.length === 0 && (
void (
inPlace ? rerunFrom(task.id, turn, entry.text, images) : resendMessage(task.id, entry.text)
@@ -963,11 +1154,11 @@ function UserMessage({
void forkTask(task.id, turn + 1).catch(failed("Couldn't copy the task"))}
/>
)}
+
);
@@ -1048,9 +1239,7 @@ function MessageEditor({
)}
-
- Cancel
-
+
}) {
+type ToolEntryData = Extract;
+
+/** 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;
+
+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;
+ 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 (
+
+
+
+ {working && }
+ {parts.join(" · ")}
+ {added > 0 && +{added} }
+ {removed > 0 && −{removed} }
+
+
+ {row.entries.map(({ index, entry }) =>
+ entry.type === "tool" ? (
+
+ ) : (
+ renderRow({ kind: "entry", index, entry })
+ ),
+ )}
+
+
+ );
+});
+
+const ToolEntry = memo(function ToolEntry({
+ task,
+ entry,
+}: {
+ task: AgentTask;
+ entry: Extract;
+}) {
const head = (
<>
@@ -1122,19 +1407,19 @@ function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract
+
);
return (
-
-
+
);
-}
+});
function SubagentStatusIcon({ status }: { status: SubagentEntry["status"] }) {
switch (status) {
@@ -1290,9 +1575,7 @@ function PermissionAnswers({
))
) : (
- answer(null)}>
- Cancel
-
+ answer(null)} />
)}
);
@@ -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 (
-
+
@@ -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 (
+
{task.status === "archived" && (
This task is archived. Unarchive it to continue the conversation.
- void executeAction("agents.unarchiveTasks", [task.id])}>
- Unarchive
-
+ void executeAction("agents.unarchiveTasks", [task.id])}
+ />
)}
@@ -1631,7 +1919,7 @@ function SessionComposer({
toolbar={
}
actions={
<>
- {busy &&
void cancelTask(task.id)} />}
+ {busy && void cancelTask(task.id)} />}
{q.prompt.text || Image }
- void sendQueuedNow(task.id, q.id).catch(fail)}
- >
- Send now
-
- void editQueued(task.id, q.id).catch(fail)}
- />
- void removeQueued(task.id, q.id).catch(fail)}
- />
+ void sendQueuedNow(task.id, q.id).catch(fail)} />
+ void editQueued(task.id, q.id).catch(fail)} />
+ void removeQueued(task.id, q.id).catch(fail)} />
))}
@@ -1782,7 +2054,7 @@ function ChangesPane({
return (
- {docked && }
+ {docked && }
Changes
@@ -1792,7 +2064,7 @@ function ChangesPane({
)}
- void refreshChanges(task.id)} />
+ void refreshChanges(task.id)} />
{!docked && }
{(head ?? task.base) && (
diff --git a/src/features/agents/messageEditing.test.tsx b/src/features/agents/messageEditing.test.tsx
index 620fc3a95..9bac52e1f 100644
--- a/src/features/agents/messageEditing.test.tsx
+++ b/src/features/agents/messageEditing.test.tsx
@@ -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((resolve) => {
+ answer = resolve;
+ }),
+ );
+ render( );
+ 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( );
const user = userEvent.setup();
diff --git a/src/features/agents/model.ts b/src/features/agents/model.ts
index d2bfe8ec9..7beb1f16b 100644
--- a/src/features/agents/model.ts
+++ b/src/features/agents/model.ts
@@ -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 {
+ const out = new Map();
+ let current: Extract | 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 {
const out = new Map();
diff --git a/src/features/agents/replyActions.ts b/src/features/agents/replyActions.ts
index 93c67803a..69cadfa95 100644
--- a/src/features/agents/replyActions.ts
+++ b/src/features/agents/replyActions.ts
@@ -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",
diff --git a/src/features/agents/sessionState.test.tsx b/src/features/agents/sessionState.test.tsx
index 205cc5d12..4b495fca6 100644
--- a/src/features/agents/sessionState.test.tsx
+++ b/src/features/agents/sessionState.test.tsx
@@ -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(),
@@ -78,15 +80,29 @@ const subagent: SubagentEntry = {
};
const input = (id: string) => ({ kind: SESSION_KIND, key: sessionKey(id), data: { task: id } });
const stream = () => document.querySelector(`.${styles.stream}`)!;
+const tool = (id: string, overrides: Partial> = {}): 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( );
+ 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( );
+ 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( );
+ 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(
+
+
+ ,
+ );
+ 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( );
+ 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( );
+ 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( );
+ 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 =
diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts
index 1538e384d..934e106e6 100644
--- a/src/features/agents/store.ts
+++ b/src/features/agents/store.ts
@@ -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 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 {
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 {
+ 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 {
const done = () => !isBusy(useAgents.getState().tasks.get(task)?.status ?? "ready");
diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json
index 2eaa56dfe..64057fddd 100644
--- a/src/platform/locales/text/en.json
+++ b/src/platform/locales/text/en.json
@@ -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."
}
diff --git a/src/platform/locales/text/es.json b/src/platform/locales/text/es.json
index df50e3194..beb81df79 100644
--- a/src/platform/locales/text/es.json
+++ b/src/platform/locales/text/es.json
@@ -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.",
diff --git a/src/platform/locales/text/fr.json b/src/platform/locales/text/fr.json
index d824b7fb3..08f620429 100644
--- a/src/platform/locales/text/fr.json
+++ b/src/platform/locales/text/fr.json
@@ -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.",
diff --git a/src/platform/locales/text/ko.json b/src/platform/locales/text/ko.json
index 0d949f73f..e4b6206d7 100644
--- a/src/platform/locales/text/ko.json
+++ b/src/platform/locales/text/ko.json
@@ -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 모양과 동작을 사용자 지정합니다. 테마와 글꼴 크기를 변경하고 시스템 설정과 알림을 구성합니다.",
diff --git a/src/platform/locales/text/pl.json b/src/platform/locales/text/pl.json
index 4e9ef4ff7..315a58a0c 100644
--- a/src/platform/locales/text/pl.json
+++ b/src/platform/locales/text/pl.json
@@ -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.",
diff --git a/src/platform/locales/text/pt.json b/src/platform/locales/text/pt.json
index 4689f49e0..05280f0da 100644
--- a/src/platform/locales/text/pt.json
+++ b/src/platform/locales/text/pt.json
@@ -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.",
diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json
index e1213a5a0..24bc6aa39 100644
--- a/src/platform/locales/text/zh.json
+++ b/src/platform/locales/text/zh.json
@@ -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.": "智能体仍在停止中。请在停止后重试。"
}