From 826e5837b1365ffc698a05fb547bf7366d9890ee Mon Sep 17 00:00:00 2001 From: SunZhiQiang <71381346+panda-student@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:57:09 -0800 Subject: [PATCH] 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 Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- e2e/agentMarkdown.spec.ts | 24 +- e2e/agentScroll.spec.ts | 23 +- src/features/agents/AgentReply.tsx | 104 ++-- src/features/agents/Session.module.css | 227 +++++++-- src/features/agents/SessionEditor.tsx | 528 +++++++++++++++----- src/features/agents/messageEditing.test.tsx | 70 ++- src/features/agents/model.ts | 17 + src/features/agents/replyActions.ts | 46 +- src/features/agents/sessionState.test.tsx | 166 +++++- src/features/agents/store.ts | 49 +- src/platform/locales/text/en.json | 8 +- src/platform/locales/text/es.json | 1 - src/platform/locales/text/fr.json | 1 - src/platform/locales/text/ko.json | 1 - src/platform/locales/text/pl.json | 1 - src/platform/locales/text/pt.json | 1 - src/platform/locales/text/zh.json | 9 +- 17 files changed, 1042 insertions(+), 234 deletions(-) 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 }) { @@ -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" && ( - + )}
); @@ -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({ )} - + }) { +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 +
{head}
); return ( -
-
+
+
{head}
{entry.output}
); -} +}); function SubagentStatusIcon({ status }: { status: SubagentEntry["status"] }) { switch (status) { @@ -1290,9 +1575,7 @@ function PermissionAnswers({ )) ) : ( - + 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])} + />
)} @@ -1631,7 +1919,7 @@ function SessionComposer({ toolbar={} actions={ <> - {busy && void cancelTask(task.id)} />} + {busy && void cancelTask(task.id)} />} {q.prompt.text || Image} - - 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 (