diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index a8dce2858..e44d6595b 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -4900,6 +4900,7 @@ export default function GatewayApp() { activeTurnKey={displayedTranscript.activeTurnKey} contentWidth={settings.customSettings.chatTranscript.width} isViewportFollowing={transcriptFollow.isFollowing} + viewportFollowing={transcriptFollowing} navRef={transcriptNavRef} onAnchorUserRowChange={setActiveFloorKey} error={transcriptError} diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index f3a110549..4e9149042 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -17,7 +17,7 @@ import { useLocale } from "@/i18n/LocaleContext"; import type { ChatFileLink } from "@/lib/chat/chatFileLinks"; import { normalizeLiveToolStatus, VIBING_STATUS } from "@/lib/chat/chatPageHelpers"; import type { HistoryMessageRef } from "@/lib/chat/conversationState"; -import { getRoundText, getRoundToolTrace } from "@/lib/chat/uiMessages"; +import { getRoundText } from "@/lib/chat/uiMessages"; import { formatUploadedFileSize, type PendingUploadedFile, @@ -60,7 +60,6 @@ import { } from "@/pages/chat/AssistantBubble"; import type { RetryAttemptRecord, TranscriptRow } from "../lib/chat/transcript/types"; -import type { GatewayTranscriptRound } from "../lib/chatUi"; import type { SectionId } from "../pages/settings/types"; import { ChatEmptyState } from "./chat/ChatEmptyState"; import { getUploadedFileTypeIcon } from "./chat/fileTypeIcons"; @@ -92,6 +91,7 @@ type GatewayTranscriptProps = { // Whether the scroll-follow engine is attached to the bottom; gates the // virtualizer's resize-compensation carve-out for live-row growth. isViewportFollowing?: () => boolean; + viewportFollowing?: boolean; // Imperative jump handle for the floor navigation rail. navRef?: MutableRefObject; // Reports the user row at the viewport's top edge (the "current floor"). @@ -168,50 +168,18 @@ function resolveNearestScrollViewport(element: HTMLElement | null) { function LiveStatusFooter(props: { status: string; isCompaction?: boolean }) { const { status, isCompaction = false } = props; return ( -
+
{isCompaction ? ( - + ) : status === VIBING_STATUS ? ( - + ) : ( - {status} + {status} )}
); } -function shouldShowLiveStatusForRounds(rounds: GatewayTranscriptRound[]) { - const activeRound = rounds[rounds.length - 1]; - if (!activeRound) { - return true; - } - const visibleToolKeys = new Set( - getRoundToolTrace(activeRound).map((item) => `${item.toolCall.id}\u0000${item.toolCall.name}`), - ); - - for (let index = activeRound.blocks.length - 1; index >= 0; index -= 1) { - const block = activeRound.blocks[index]; - if (!block) { - continue; - } - if (block.kind === "tool") { - if (visibleToolKeys.has(`${block.item.toolCall.id}\u0000${block.item.toolCall.name}`)) { - return true; - } - continue; - } - if (block.kind === "hostedSearch") { - return false; - } - if (block.text.trim() === "") { - continue; - } - return block.kind !== "text"; - } - - return true; -} - function HistoryLoadingState(props: { title?: string }) { const title = props.title?.trim(); return ( @@ -1184,6 +1152,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr contentWidth: number; scrollViewport: HTMLDivElement | null; isViewportFollowing?: () => boolean; + viewportFollowing: boolean; navRef?: MutableRefObject; onAnchorUserRowChange?: (rowKey: string | null) => void; hasMoreHistory?: boolean; @@ -1218,6 +1187,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr contentWidth, scrollViewport, isViewportFollowing, + viewportFollowing, navRef, onAnchorUserRowChange, hasMoreHistory, @@ -1361,16 +1331,10 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr gap: TRANSCRIPT_ROW_GAP, overscan: TRANSCRIPT_ROW_OVERSCAN_COUNT, enabled: scrollViewport !== null, - // End-anchored: prepends (loading earlier history) keep the visible item - // stable upstream via keyed anchoring, growth of the last row while the - // viewport is virtually at the end compensates by the total-size delta, - // and estimate→measure corrections keep the bottom pinned. The threshold - // matches scrollFollowCore's BOTTOM_ATTACH_THRESHOLD_PX so both engines - // agree on what "at the bottom" means. followOnAppend stays off: its - // DOM-distance re-follow would conflict with the follow reducer's - // "shrink clamps never re-attach" contract — appends while following are - // already pinned by the reducer. - anchorTo: "end", + // End anchoring is enabled only for a detached reader so keyed prepends + // preserve the visible row. While following, start anchoring disables the + // virtualizer's bottom correction and leaves live growth to useScrollFollow. + anchorTo: viewportFollowing ? "start" : "end", scrollEndThreshold: 8, initialMeasurementsCache, rangeExtractor: (range) => extractLiveRange(range, forceMountStartRef.current), @@ -1378,8 +1342,8 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr // TanStack exposes the resize-compensation predicate as an instance field, // not an option; reassigning per render keeps the closure's inputs current. - // It only governs the detached reader — while virtually at the end, the - // upstream end-anchor compensation takes priority over this predicate. + // While following it rejects every virtualizer correction; while detached + // it retains estimate/measurement anchoring for rows above the viewport. transcriptVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = createLiveRowScrollAdjustPolicy({ getLiveStartIndex: () => forceMountStartRef.current, @@ -1701,16 +1665,11 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr const rowIndex = virtualRow.index - leadingOffset; const isLatestLiveAssistant = rowIndex === liveAssistantIndex; const isLatestLiveStreaming = isStreaming && isLatestLiveAssistant; - const shouldShowLiveStatus = - isLatestLiveStreaming && - Boolean(displayedToolStatus) && - !displayedToolStatusIsCompaction && - shouldShowLiveStatusForRounds(row.rounds); - const liveStatusText = shouldShowLiveStatus ? (displayedToolStatus ?? "") : ""; return (
- {shouldShowLiveStatus ? : null} + {isLatestLiveStreaming ? ( + + ) : null} {isLatestLiveStreaming && !shouldShowPendingLiveBubble && retryAttempts && @@ -1796,6 +1760,7 @@ export function GatewayTranscript({ activeTurnKey = null, contentWidth = DEFAULT_CHAT_TRANSCRIPT_WIDTH, isViewportFollowing, + viewportFollowing = false, navRef, onAnchorUserRowChange, error, @@ -1884,6 +1849,7 @@ export function GatewayTranscript({ contentWidth={contentWidth} scrollViewport={transcriptScrollViewport} isViewportFollowing={isViewportFollowing} + viewportFollowing={viewportFollowing} navRef={navRef} onAnchorUserRowChange={onAnchorUserRowChange} hasMoreHistory={hasMoreHistory} diff --git a/crates/agent-gateway/web/src/components/chat/ThinkingActivity.tsx b/crates/agent-gateway/web/src/components/chat/ThinkingActivity.tsx new file mode 100644 index 000000000..8389245df --- /dev/null +++ b/crates/agent-gateway/web/src/components/chat/ThinkingActivity.tsx @@ -0,0 +1,149 @@ +import { useCallback, useId, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useLocale } from "../../i18n"; +import type { ChatFileLink } from "../../lib/chat/chatFileLinks"; +import { + resolveThinkingOverlayPlacement, + type ThinkingOverlayPlacement, +} from "../../lib/chat/thinkingOverlayModel"; +import { ChevronRight, Lightbulb } from "../icons"; +import { Markdown } from "../Markdown"; + +export function ThinkingActivity(props: { + text: string; + isRunning?: boolean; + renderMode: "streaming" | "static"; + workdir?: string; + onOpenFileLink?: (link: ChatFileLink) => void; +}) { + const { text, isRunning = false, renderMode, workdir, onOpenFileLink } = props; + const { t } = useLocale(); + const [open, setOpen] = useState(false); + const [placement, setPlacement] = useState(null); + const triggerRef = useRef(null); + const panelRef = useRef(null); + const panelId = useId(); + const hasText = /\S/.test(text); + + const updatePlacement = useCallback(() => { + const trigger = triggerRef.current; + if (!trigger) return; + setPlacement( + resolveThinkingOverlayPlacement(trigger.getBoundingClientRect(), { + width: window.innerWidth, + height: window.innerHeight, + }), + ); + }, []); + + const close = useCallback((restoreFocus = false) => { + setOpen(false); + setPlacement(null); + if (restoreFocus) { + requestAnimationFrame(() => triggerRef.current?.focus({ preventScroll: true })); + } + }, []); + + useLayoutEffect(() => { + if (!open) return; + updatePlacement(); + const focusFrame = requestAnimationFrame(() => + panelRef.current?.focus({ preventScroll: true }), + ); + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) return; + close(); + }; + const handleFocusIn = (event: FocusEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) return; + close(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + close(true); + }; + window.addEventListener("pointerdown", handlePointerDown, true); + window.addEventListener("focusin", handleFocusIn, true); + window.addEventListener("keydown", handleKeyDown, true); + window.addEventListener("resize", updatePlacement); + window.addEventListener("scroll", updatePlacement, true); + return () => { + cancelAnimationFrame(focusFrame); + window.removeEventListener("pointerdown", handlePointerDown, true); + window.removeEventListener("focusin", handleFocusIn, true); + window.removeEventListener("keydown", handleKeyDown, true); + window.removeEventListener("resize", updatePlacement); + window.removeEventListener("scroll", updatePlacement, true); + }; + }, [close, open, updatePlacement]); + + if (!hasText) return null; + + return ( +
+ + {open && placement + ? createPortal( + , + document.body, + ) + : null} +
+ ); +} diff --git a/crates/agent-gateway/web/src/lib/chat-scroll/framePinController.ts b/crates/agent-gateway/web/src/lib/chat-scroll/framePinController.ts new file mode 100644 index 000000000..c92bd5440 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat-scroll/framePinController.ts @@ -0,0 +1,31 @@ +export type ScheduleFrame = (callback: () => void) => number; +export type CancelFrame = (handle: number) => void; + +export function createFramePinController( + write: () => void, + scheduleFrame: ScheduleFrame, + cancelFrame: CancelFrame, +) { + let pendingFrame: number | null = null; + + const cancel = () => { + if (pendingFrame === null) return; + cancelFrame(pendingFrame); + pendingFrame = null; + }; + + const schedule = () => { + if (pendingFrame !== null) return; + pendingFrame = scheduleFrame(() => { + pendingFrame = null; + write(); + }); + }; + + const flush = () => { + cancel(); + write(); + }; + + return { cancel, flush, schedule }; +} diff --git a/crates/agent-gateway/web/src/lib/chat-scroll/useScrollFollow.ts b/crates/agent-gateway/web/src/lib/chat-scroll/useScrollFollow.ts index 78b0eac76..302e2b72c 100644 --- a/crates/agent-gateway/web/src/lib/chat-scroll/useScrollFollow.ts +++ b/crates/agent-gateway/web/src/lib/chat-scroll/useScrollFollow.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - +import { createFramePinController } from "./framePinController"; import { createFollowState, DEFAULT_FOLLOW_CONFIG, @@ -113,6 +113,18 @@ export function useScrollFollow(args: UseScrollFollowArgs): { configRef.current = { ...DEFAULT_FOLLOW_CONFIG, ...args.config }; const [following, setFollowing] = useState(true); const jumpRafRef = useRef(null); + const pinController = useMemo( + () => + createFramePinController( + () => { + const el = boundViewportRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, + (callback) => requestAnimationFrame(callback), + (handle) => cancelAnimationFrame(handle), + ), + [], + ); const cancelJumpAnimation = useCallback(() => { if (jumpRafRef.current !== null) { @@ -124,25 +136,28 @@ export function useScrollFollow(args: UseScrollFollowArgs): { const pinToBottom = useCallback(() => { // An instant pin supersedes any in-flight jump animation. cancelJumpAnimation(); - const el = boundViewportRef.current; - if (el) { - el.scrollTop = el.scrollHeight; - } - }, [cancelJumpAnimation]); + pinController.flush(); + }, [cancelJumpAnimation, pinController]); + + const schedulePinToBottom = useCallback(() => { + cancelJumpAnimation(); + pinController.schedule(); + }, [cancelJumpAnimation, pinController]); const dispatch = useCallback( - (event: FollowEvent) => { + (event: FollowEvent, pinMode: "immediate" | "frame" = "immediate") => { const wasFollowing = stateRef.current.following; const step = reduceFollowEvent(stateRef.current, event, configRef.current); stateRef.current = step.state; if (step.pin) { - pinToBottom(); + if (pinMode === "frame") schedulePinToBottom(); + else pinToBottom(); } if (step.state.following !== wasFollowing) { setFollowing(step.state.following); } }, - [pinToBottom], + [pinToBottom, schedulePinToBottom], ); const stickToBottom = useCallback(() => { @@ -350,7 +365,7 @@ export function useScrollFollow(args: UseScrollFollowArgs): { typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { - dispatch({ type: "contentGrowth", gap: getGap() }); + dispatch({ type: "contentGrowth", gap: getGap() }, "frame"); }); resizeObserver?.observe(viewport); if (growthTarget instanceof Element) { @@ -372,6 +387,7 @@ export function useScrollFollow(args: UseScrollFollowArgs): { } document.removeEventListener("visibilitychange", handleVisibilityChange); resizeObserver?.disconnect(); + pinController.cancel(); cancelJumpAnimation(); boundViewportRef.current = null; }; @@ -382,6 +398,7 @@ export function useScrollFollow(args: UseScrollFollowArgs): { enabled, listenerRoot, pinToBottom, + pinController, trackKeys, viewport, ]); diff --git a/crates/agent-gateway/web/src/lib/chat/thinkingOverlayModel.ts b/crates/agent-gateway/web/src/lib/chat/thinkingOverlayModel.ts new file mode 100644 index 000000000..9afe7ab3f --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/thinkingOverlayModel.ts @@ -0,0 +1,59 @@ +export type ThinkingOverlayRect = { + left: number; + right: number; + top: number; + bottom: number; + width: number; + height: number; +}; + +export type ThinkingOverlayViewport = { width: number; height: number }; + +export type ThinkingOverlayPlacement = { + side: "above" | "below"; + left: number; + width: number; + maxHeight: number; + top?: number; + bottom?: number; +}; + +const VIEWPORT_MARGIN_PX = 12; +const OVERLAY_GAP_PX = 8; +const MAX_OVERLAY_WIDTH_PX = 640; +const MIN_PREFERRED_HEIGHT_PX = 180; + +export function resolveThinkingOverlayPlacement( + trigger: ThinkingOverlayRect, + viewport: ThinkingOverlayViewport, +): ThinkingOverlayPlacement { + const viewportWidth = Math.max(1, viewport.width); + const horizontalMargin = Math.min(VIEWPORT_MARGIN_PX, Math.max(0, (viewportWidth - 1) / 2)); + const availableWidth = Math.max(1, viewportWidth - horizontalMargin * 2); + const width = Math.min(MAX_OVERLAY_WIDTH_PX, availableWidth); + const centeredLeft = trigger.left + (trigger.width - width) / 2; + const left = Math.min( + Math.max(horizontalMargin, centeredLeft), + Math.max(horizontalMargin, viewportWidth - horizontalMargin - width), + ); + const above = Math.max(0, trigger.top - OVERLAY_GAP_PX - VIEWPORT_MARGIN_PX); + const below = Math.max(0, viewport.height - trigger.bottom - OVERLAY_GAP_PX - VIEWPORT_MARGIN_PX); + const side = above >= MIN_PREFERRED_HEIGHT_PX || above >= below ? "above" : "below"; + + if (side === "above") { + return { + side, + left, + width, + maxHeight: above, + bottom: viewport.height - trigger.top + OVERLAY_GAP_PX, + }; + } + return { + side, + left, + width, + maxHeight: below, + top: trigger.bottom + OVERLAY_GAP_PX, + }; +} diff --git a/crates/agent-gateway/web/src/lib/transcript-virtual/liveScrollAdjustPolicy.ts b/crates/agent-gateway/web/src/lib/transcript-virtual/liveScrollAdjustPolicy.ts index d2ad8f63e..a03eb7864 100644 --- a/crates/agent-gateway/web/src/lib/transcript-virtual/liveScrollAdjustPolicy.ts +++ b/crates/agent-gateway/web/src/lib/transcript-virtual/liveScrollAdjustPolicy.ts @@ -3,10 +3,9 @@ import type { VirtualItem, Virtualizer } from "@tanstack/react-virtual"; // Resize-compensation policy for the transcript virtualizer (virtual-core // 3.17.x semantics). // -// With `anchorTo: 'end'`, virtual-core handles the bottom-pinned case itself: -// while the viewport is virtually at the end, `resizeItem` compensates by the -// total-size delta and this predicate's verdict is ignored. This policy -// therefore only governs the detached reader. +// `useScrollFollow` is the sole owner of live bottom pinning. While following, +// this predicate rejects every virtualizer resize correction so one content +// growth batch cannot first move by an estimate delta and then pin again. // // It replicates the upstream default and carves out exactly one case the // default gets wrong: the live streaming row grown taller than the viewport. @@ -21,8 +20,7 @@ import type { VirtualItem, Virtualizer } from "@tanstack/react-virtual"; // measurement always compensates (the estimate→actual delta must land // regardless of scroll direction) and a re-measurement is skipped during // backward scroll (the upstream "items jump while scrolling up" fix); -// - while following, the compensation cooperates with the scroll-follow pin, -// so it stays on; +// - while following, all compensation is delegated to scroll-follow; // - live-row shrinks (delta < 0, e.g. a thinking block collapsing near the // row's top) keep compensating so content under the reader stays put. export type LiveRowScrollAdjustPolicyArgs = { @@ -45,6 +43,9 @@ export function createLiveRowScrollAdjustPolicy< ) => boolean { const { getLiveStartIndex, isFollowing } = args; return (item, delta, instance) => { + if (isFollowing()) { + return false; + } // Un-echoed scroll writes accumulate in a private field until the next // scroll event; the upstream default folds them into the comparison, so // mirror that (fall back to 0 if the field ever disappears). @@ -70,8 +71,7 @@ export function createLiveRowScrollAdjustPolicy< liveStartIndex >= 0 && item.index >= liveStartIndex && delta > 0 && - item.end > viewportTop && - !isFollowing() + item.end > viewportTop ) { return false; } diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx index 9afdf1ad9..cf136c812 100644 --- a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx +++ b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx @@ -1,5 +1,6 @@ -import { memo, useEffect, useMemo, useRef, useState } from "react"; -import { ChevronRight, Lightbulb, RefreshCw } from "../../../components/icons"; +import { memo, useMemo, useState } from "react"; +import { ThinkingActivity } from "../../../components/chat/ThinkingActivity"; +import { ChevronRight, RefreshCw } from "../../../components/icons"; import { Markdown } from "../../../components/Markdown"; import { useLocale } from "../../../i18n"; import type { ChatFileLink } from "../../../lib/chat/chatFileLinks"; @@ -17,74 +18,6 @@ import { UsagePanel } from "./UsagePanel"; const EMPTY_RUNNING_TOOL_CALL_IDS: string[] = []; -const ThinkingBlock = memo(function ThinkingBlock({ - text, - open, - isRunning, - renderMode, - workdir, - onOpenFileLink, -}: { - text: string; - open?: boolean; - isRunning?: boolean; - renderMode: "streaming" | "static"; - workdir?: string; - onOpenFileLink?: (link: ChatFileLink) => void; -}) { - const hasText = /\S/.test(text || ""); - const { t } = useLocale(); - const [isOpen, setIsOpen] = useState(typeof open === "boolean" ? open : false); - const userInteractedRef = useRef(false); - useEffect(() => { - if (!userInteractedRef.current && typeof open === "boolean") { - setIsOpen(open); - } - }, [open]); - - if (!hasText) return null; - - return ( -
- - - {() => ( -
- -
- )} -
-
- ); -}); - // Expandable per-attempt stream-retry history for the live run, mirrored // from the desktop app's RetryDetailsBlock (agent-gui RoundContent.tsx). export const RetryDetailsBlock = memo(function RetryDetailsBlock({ @@ -261,10 +194,9 @@ export const RoundContent = memo(function RoundContent(props: { {visibleGroupedBlocks.map((block) => { if (block.kind === "thinking") { return ( - @@ -36,7 +36,9 @@ export function AssistantStatus({ aria-hidden="true" className={cn("h-3.5 w-3.5 shrink-0 animate-spin", iconClassName)} /> - {children} + + {children} + ); } diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolTraceGroup.tsx b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolTraceGroup.tsx index b01ccb6f9..7cb86f0d1 100644 --- a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolTraceGroup.tsx +++ b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolTraceGroup.tsx @@ -84,6 +84,19 @@ function ToolTraceGroupInner(props: { const ToolIcon = allBash ? Terminal : meta.Icon; const [open, setOpen] = useState(false); + if (items.length === 1) { + const item = items[0]; + return item ? ( + + ) : null; + } + const statusLabel = counts.failed > 0 ? `${counts.failed} ${t("chat.tool.failed")}` diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/assistantBubbleUtils.ts b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/assistantBubbleUtils.ts index 8ab692758..96a99023a 100644 --- a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/assistantBubbleUtils.ts +++ b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/assistantBubbleUtils.ts @@ -244,23 +244,14 @@ export function groupRoundBlocks(blocks: UiRound["blocks"]): GroupedRoundBlock[] const flushPendingTools = () => { if (pendingTools.length === 0) return; - if (pendingTools.length === 1) { - const item = pendingTools[0]; - groupedBlocks.push({ - kind: "tool", - key: `tool-${getToolTraceKey(item, pendingStartIndex)}`, - item, - }); - } else { - groupedBlocks.push({ - kind: "toolGroup", - // Anchored to the group's start only: appending tools to a streaming - // group must keep the key stable, or the remount would wipe the - // user's manual expand/collapse state mid-run. - key: `tool-group-${pendingStartIndex}-${getToolTraceKey(pendingTools[0], pendingStartIndex)}`, - items: pendingTools, - }); - } + groupedBlocks.push({ + kind: "toolGroup", + // The wrapper exists from the first ordinary tool onward. Appending a + // second tool therefore updates one activity in place instead of + // replacing a `tool` row with a differently keyed `toolGroup` row. + key: `tool-group-${getToolTraceKey(pendingTools[0], pendingStartIndex)}`, + items: pendingTools, + }); pendingTools = []; }; diff --git a/crates/agent-gateway/web/test/assistant-bubble-utils.test.mjs b/crates/agent-gateway/web/test/assistant-bubble-utils.test.mjs index 201d874b2..6844d65ed 100644 --- a/crates/agent-gateway/web/test/assistant-bubble-utils.test.mjs +++ b/crates/agent-gateway/web/test/assistant-bubble-utils.test.mjs @@ -7,7 +7,7 @@ import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; const rootDir = fileURLToPath(new URL("../", import.meta.url)); const loader = createWebModuleLoader({ rootDir }); const { BUILTIN_TOOL_CATALOG } = loader.loadModule("src/lib/tools/builtinToolCatalog.ts"); -const { isBuiltinShareToolName } = loader.loadModule( +const { groupRoundBlocks, isBuiltinShareToolName } = loader.loadModule( "src/pages/chat/assistant-bubble/assistantBubbleUtils.ts", ); @@ -18,3 +18,55 @@ test("shared history recognizes every catalog tool as builtin", () => { assert.equal(isBuiltinShareToolName("mcp_docs_search"), true); assert.equal(isBuiltinShareToolName("CustomTool"), false); }); + +test("ordinary tool activity keeps one group identity as later tools append", () => { + const tool = (id) => ({ + kind: "tool", + item: { toolCall: { type: "toolCall", id, name: "Bash", arguments: {} } }, + }); + const first = groupRoundBlocks([tool("call-1")]); + const appended = groupRoundBlocks([tool("call-1"), tool("call-2")]); + + assert.equal(first.length, 1); + assert.equal(appended.length, 1); + assert.equal(first[0].kind, "toolGroup"); + assert.equal(appended[0].kind, "toolGroup"); + assert.equal(appended[0].key, first[0].key); +}); + +test("special tool result updates preserve their direct activity identity", () => { + for (const name of ["TodoWrite", "AskUserQuestion", "Image", "Agent"]) { + const pendingItem = { + toolCall: { type: "toolCall", id: `call-${name}`, name, arguments: {} }, + }; + const settledItem = { + ...pendingItem, + toolResult: { + role: "toolResult", + toolCallId: `call-${name}`, + content: [], + isError: name === "Image", + }, + }; + const pending = groupRoundBlocks([{ kind: "tool", item: pendingItem }]); + const settled = groupRoundBlocks([{ kind: "tool", item: settledItem }]); + + assert.equal(pending.length, 1, name); + assert.equal(settled.length, 1, name); + assert.equal(pending[0].kind, "tool", name); + assert.equal(settled[0].kind, "tool", name); + assert.equal(settled[0].key, pending[0].key, name); + } +}); + +test("hosted search activity keeps one group identity as later searches append", () => { + const first = groupRoundBlocks([{ kind: "hostedSearch", item: { id: "search-1" } }]); + const appended = groupRoundBlocks([ + { kind: "hostedSearch", item: { id: "search-1" } }, + { kind: "hostedSearch", item: { id: "search-2" } }, + ]); + + assert.equal(first[0].kind, "hostedSearchGroup"); + assert.equal(appended[0].kind, "hostedSearchGroup"); + assert.equal(appended[0].key, first[0].key); +}); diff --git a/crates/agent-gateway/web/test/assistant-status.test.mjs b/crates/agent-gateway/web/test/assistant-status.test.mjs index b4d335a2b..8b046d3cc 100644 --- a/crates/agent-gateway/web/test/assistant-status.test.mjs +++ b/crates/agent-gateway/web/test/assistant-status.test.mjs @@ -24,8 +24,13 @@ const { AssistantStatus } = loader.loadModule( test("assistant running status keeps its spinner animated", () => { const status = AssistantStatus({ children: "Vibing" }); const icon = status.props.children[0]; + const text = status.props.children[1]; assert.equal(icon.type, Loader2); assert.match(icon.props.className, /(?:^|\s)animate-spin(?:\s|$)/); assert.doesNotMatch(icon.props.className, /(?:^|\s)motion-reduce:animate-none(?:\s|$)/); + assert.match(status.props.className, /(?:^|\s)min-w-0(?:\s|$)/); + assert.match(status.props.className, /(?:^|\s)max-w-full(?:\s|$)/); + assert.match(text.props.className, /(?:^|\s)truncate(?:\s|$)/); + assert.match(text.props.className, /(?:^|\s)whitespace-nowrap(?:\s|$)/); }); diff --git a/crates/agent-gateway/web/test/chat-file-links.test.mjs b/crates/agent-gateway/web/test/chat-file-links.test.mjs index dd12466dd..ea9eea710 100644 --- a/crates/agent-gateway/web/test/chat-file-links.test.mjs +++ b/crates/agent-gateway/web/test/chat-file-links.test.mjs @@ -83,6 +83,7 @@ test("Gateway historical and streaming rows keep the explicit file-open prop cha const files = [ "../src/app/GatewayApp.tsx", "../src/components/GatewayTranscript.tsx", + "../src/components/chat/ThinkingActivity.tsx", "../src/pages/chat/AssistantBubble.tsx", "../src/pages/chat/assistant-bubble/RoundContent.tsx", ]; @@ -96,8 +97,16 @@ test("Gateway historical and streaming rows keep the explicit file-open prop cha "utf8", ); assert.match(roundContent, /isStreaming \? "streaming" : "static"/); - assert.ok((roundContent.match(/onOpenFileLink=\{onOpenFileLink\}/g) ?? []).length >= 3); - assert.ok((roundContent.match(/workdir=\{workdir\}/g) ?? []).length >= 3); + assert.ok((roundContent.match(/onOpenFileLink=\{onOpenFileLink\}/g) ?? []).length >= 2); + assert.ok((roundContent.match(/workdir=\{workdir\}/g) ?? []).length >= 2); + + const thinkingActivity = fs.readFileSync( + fileURLToPath(new URL("../src/components/chat/ThinkingActivity.tsx", import.meta.url)), + "utf8", + ); + assert.match(thinkingActivity, / { + const callbacks = []; + let writes = 0; + const controller = createFramePinController( + () => { + writes += 1; + }, + (callback) => { + callbacks.push(callback); + return callbacks.length; + }, + () => {}, + ); + controller.schedule(); + controller.schedule(); + controller.schedule(); + assert.equal(callbacks.length, 1); + callbacks.shift()(); + assert.equal(writes, 1); +}); diff --git a/crates/agent-gateway/web/test/live-markdown-caret.test.mjs b/crates/agent-gateway/web/test/live-markdown-caret.test.mjs new file mode 100644 index 000000000..393e7cc0b --- /dev/null +++ b/crates/agent-gateway/web/test/live-markdown-caret.test.mjs @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; + +const roundContentSource = fs.readFileSync( + new URL("../src/pages/chat/assistant-bubble/RoundContent.tsx", import.meta.url), + "utf8", +); + +test("live assistant text does not render a trailing caret row", () => { + assert.doesNotMatch(roundContentSource, /showCaret=/); +}); diff --git a/crates/agent-gateway/web/test/live-scroll-adjust-policy.test.mjs b/crates/agent-gateway/web/test/live-scroll-adjust-policy.test.mjs index bd5603acd..7fe4853e1 100644 --- a/crates/agent-gateway/web/test/live-scroll-adjust-policy.test.mjs +++ b/crates/agent-gateway/web/test/live-scroll-adjust-policy.test.mjs @@ -78,10 +78,11 @@ test("detached reader inside the growing live row is left alone (streaming creep assert.equal(policy(item, 60, makeInstance({ scrollOffset: 3000, measuredKeys: [5] })), false); }); -test("the same live-row growth while following keeps compensating (pin assist)", () => { +test("following delegates every resize correction to the scroll-follow owner", () => { const policy = makePolicy({ liveStartIndex: 5, following: true }); const item = makeItem({ index: 5, start: 400, size: 5000 }); - assert.equal(policy(item, 60, makeInstance({ scrollOffset: 3000, measuredKeys: [5] })), true); + assert.equal(policy(item, 60, makeInstance({ scrollOffset: 3000, measuredKeys: [5] })), false); + assert.equal(policy(item, -80, makeInstance({ scrollOffset: 3000, measuredKeys: [5] })), false); }); test("live-row shrink keeps compensating so content under the reader stays put", () => { diff --git a/crates/agent-gateway/web/test/live-status-layout.test.mjs b/crates/agent-gateway/web/test/live-status-layout.test.mjs new file mode 100644 index 000000000..2a412d624 --- /dev/null +++ b/crates/agent-gateway/web/test/live-status-layout.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; + +const transcriptSource = fs.readFileSync( + new URL("../src/components/GatewayTranscript.tsx", import.meta.url), + "utf8", +); + +test("the streaming assistant always owns one stable live-status footer", () => { + assert.doesNotMatch(transcriptSource, /function shouldShowLiveStatusForRounds/); + assert.match( + transcriptSource, + /isLatestLiveStreaming\s*\?\s*\(\s* { + const above = resolveThinkingOverlayPlacement( + { left: 200, right: 700, top: 500, bottom: 532, width: 500, height: 32 }, + { width: 1200, height: 800 }, + ); + assert.equal(above.side, "above"); + const narrow = resolveThinkingOverlayPlacement( + { left: 8, right: 312, top: 60, bottom: 92, width: 304, height: 32 }, + { width: 320, height: 480 }, + ); + assert.equal(narrow.width, 296); +}); + +test("keeps a renderable overlay inside an extremely narrow viewport", () => { + const placement = resolveThinkingOverlayPlacement( + { left: 0, right: 8, top: 60, bottom: 92, width: 8, height: 32 }, + { width: 8, height: 480 }, + ); + assert.equal(placement.left, 3.5); + assert.equal(placement.width, 1); + assert.ok(placement.left + placement.width <= 8); +}); + +test("thinking details use a portal overlay instead of inline collapse", () => { + assert.match(componentSource, /createPortal/); + assert.match(componentSource, /role="dialog"/); + assert.match(componentSource, /className="fixed/); + assert.doesNotMatch(componentSource, /LazyCollapse/); +}); diff --git a/crates/agent-gateway/web/test/transcript-rows.test.mjs b/crates/agent-gateway/web/test/transcript-rows.test.mjs index c7ef4222a..fc8ca1a5c 100644 --- a/crates/agent-gateway/web/test/transcript-rows.test.mjs +++ b/crates/agent-gateway/web/test/transcript-rows.test.mjs @@ -115,6 +115,116 @@ test("buildTurnRows emits the user bubble before any assistant content, tagged w assert.equal(rows[1].turnKey, "req:c1"); }); +test("interleaved thinking and tool results keep one assistant row and stable block identities", () => { + const prefixEntries = [ + { id: "think-1", kind: "thinking", text: "reasoning", round: 1 }, + { + id: "tool-call-1", + kind: "tool_call", + round: 1, + toolCall: { type: "toolCall", id: "call-1", name: "Bash", arguments: {} }, + }, + ]; + const prefix = buildRowsFromEntries(prefixEntries, "stream"); + const complete = buildRowsFromEntries( + [ + ...prefixEntries, + { + id: "tool-result-1", + kind: "tool_result", + round: 1, + toolResult: { + role: "toolResult", + toolCallId: "call-1", + content: [], + isError: false, + }, + }, + { id: "think-2", kind: "thinking", text: "next reasoning", round: 1 }, + ], + "stream", + ); + + assert.equal(prefix.length, 1); + assert.equal(complete.length, 1); + assert.equal(complete[0].key, prefix[0].key); + const blockIdentity = (row) => + row.rounds[0].blocks.map((block) => + block.kind === "tool" ? `tool:${block.item.toolCall.id}` : `${block.kind}:${block.id}`, + ); + assert.deepEqual(blockIdentity(complete[0]).slice(0, 2), blockIdentity(prefix[0])); + assert.equal(complete[0].rounds[0].blocks.at(-1).id, "thinking-2"); +}); + +test("out-of-order and duplicate tool results update their first-seen tools in place", () => { + let turn = createTurn({ key: "req:out-of-order", runId: "run-out-of-order" }); + turn = applyEventToTurn(turn, { type: "thinking", text: "before tools", round: 1 }); + turn = applyEventToTurn(turn, { + type: "tool_call", + id: "call-a", + name: "Bash", + arguments: { command: "A" }, + round: 1, + }); + turn = applyEventToTurn(turn, { + type: "tool_call", + id: "call-b", + name: "Read", + arguments: { path: "B" }, + round: 1, + }); + const prefix = buildTurnRows(turn).find((row) => row.kind === "assistant"); + assert.ok(prefix); + const prefixBlocks = prefix.rounds[0].blocks.map((block) => + block.kind === "tool" ? `tool:${block.item.toolCall.id}` : `${block.kind}:${block.id}`, + ); + + const resultB = { + type: "tool_result", + id: "call-b", + name: "Read", + content: [{ type: "text", text: "B complete" }], + isError: false, + round: 1, + }; + turn = applyEventToTurn(turn, resultB); + const afterFirstB = turn; + turn = applyEventToTurn(turn, resultB); + assert.deepEqual(turn.entries, afterFirstB.entries, "duplicate result is idempotent"); + turn = applyEventToTurn(turn, { + type: "tool_result", + id: "call-a", + name: "Bash", + content: [{ type: "text", text: "A failed" }], + isError: true, + round: 1, + }); + turn = applyEventToTurn(turn, { type: "thinking", text: "after tools", round: 1 }); + + const complete = buildTurnRows(turn).find((row) => row.kind === "assistant"); + assert.ok(complete); + assert.equal(complete.key, prefix.key); + const completeBlocks = complete.rounds[0].blocks; + assert.deepEqual( + completeBlocks.slice(0, prefixBlocks.length).map((block) => + block.kind === "tool" ? `tool:${block.item.toolCall.id}` : `${block.kind}:${block.id}`, + ), + prefixBlocks, + ); + const trace = completeBlocks + .filter((block) => block.kind === "tool") + .map((block) => ({ + id: block.item.toolCall.id, + result: block.item.toolResult?.content?.[0]?.text, + isError: block.item.toolResult?.isError, + })); + assert.deepEqual(trace, [ + { id: "call-a", result: "A failed", isError: true }, + { id: "call-b", result: "B complete", isError: false }, + ]); + assert.equal(completeBlocks.at(-1).kind, "thinking"); +}); + test("dedupeRowKeys suffixes collisions deterministically without touching unique keys", () => { const rows = [ { key: "a", origin: "history", kind: "error", text: "1" }, diff --git a/crates/agent-gui/src/components/chat/ThinkingActivity.tsx b/crates/agent-gui/src/components/chat/ThinkingActivity.tsx new file mode 100644 index 000000000..8389245df --- /dev/null +++ b/crates/agent-gui/src/components/chat/ThinkingActivity.tsx @@ -0,0 +1,149 @@ +import { useCallback, useId, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useLocale } from "../../i18n"; +import type { ChatFileLink } from "../../lib/chat/chatFileLinks"; +import { + resolveThinkingOverlayPlacement, + type ThinkingOverlayPlacement, +} from "../../lib/chat/thinkingOverlayModel"; +import { ChevronRight, Lightbulb } from "../icons"; +import { Markdown } from "../Markdown"; + +export function ThinkingActivity(props: { + text: string; + isRunning?: boolean; + renderMode: "streaming" | "static"; + workdir?: string; + onOpenFileLink?: (link: ChatFileLink) => void; +}) { + const { text, isRunning = false, renderMode, workdir, onOpenFileLink } = props; + const { t } = useLocale(); + const [open, setOpen] = useState(false); + const [placement, setPlacement] = useState(null); + const triggerRef = useRef(null); + const panelRef = useRef(null); + const panelId = useId(); + const hasText = /\S/.test(text); + + const updatePlacement = useCallback(() => { + const trigger = triggerRef.current; + if (!trigger) return; + setPlacement( + resolveThinkingOverlayPlacement(trigger.getBoundingClientRect(), { + width: window.innerWidth, + height: window.innerHeight, + }), + ); + }, []); + + const close = useCallback((restoreFocus = false) => { + setOpen(false); + setPlacement(null); + if (restoreFocus) { + requestAnimationFrame(() => triggerRef.current?.focus({ preventScroll: true })); + } + }, []); + + useLayoutEffect(() => { + if (!open) return; + updatePlacement(); + const focusFrame = requestAnimationFrame(() => + panelRef.current?.focus({ preventScroll: true }), + ); + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) return; + close(); + }; + const handleFocusIn = (event: FocusEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) return; + close(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + close(true); + }; + window.addEventListener("pointerdown", handlePointerDown, true); + window.addEventListener("focusin", handleFocusIn, true); + window.addEventListener("keydown", handleKeyDown, true); + window.addEventListener("resize", updatePlacement); + window.addEventListener("scroll", updatePlacement, true); + return () => { + cancelAnimationFrame(focusFrame); + window.removeEventListener("pointerdown", handlePointerDown, true); + window.removeEventListener("focusin", handleFocusIn, true); + window.removeEventListener("keydown", handleKeyDown, true); + window.removeEventListener("resize", updatePlacement); + window.removeEventListener("scroll", updatePlacement, true); + }; + }, [close, open, updatePlacement]); + + if (!hasText) return null; + + return ( +
+ + {open && placement + ? createPortal( + , + document.body, + ) + : null} +
+ ); +} diff --git a/crates/agent-gui/src/lib/chat-scroll/framePinController.ts b/crates/agent-gui/src/lib/chat-scroll/framePinController.ts new file mode 100644 index 000000000..c92bd5440 --- /dev/null +++ b/crates/agent-gui/src/lib/chat-scroll/framePinController.ts @@ -0,0 +1,31 @@ +export type ScheduleFrame = (callback: () => void) => number; +export type CancelFrame = (handle: number) => void; + +export function createFramePinController( + write: () => void, + scheduleFrame: ScheduleFrame, + cancelFrame: CancelFrame, +) { + let pendingFrame: number | null = null; + + const cancel = () => { + if (pendingFrame === null) return; + cancelFrame(pendingFrame); + pendingFrame = null; + }; + + const schedule = () => { + if (pendingFrame !== null) return; + pendingFrame = scheduleFrame(() => { + pendingFrame = null; + write(); + }); + }; + + const flush = () => { + cancel(); + write(); + }; + + return { cancel, flush, schedule }; +} diff --git a/crates/agent-gui/src/lib/chat-scroll/useScrollFollow.ts b/crates/agent-gui/src/lib/chat-scroll/useScrollFollow.ts index 78b0eac76..302e2b72c 100644 --- a/crates/agent-gui/src/lib/chat-scroll/useScrollFollow.ts +++ b/crates/agent-gui/src/lib/chat-scroll/useScrollFollow.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - +import { createFramePinController } from "./framePinController"; import { createFollowState, DEFAULT_FOLLOW_CONFIG, @@ -113,6 +113,18 @@ export function useScrollFollow(args: UseScrollFollowArgs): { configRef.current = { ...DEFAULT_FOLLOW_CONFIG, ...args.config }; const [following, setFollowing] = useState(true); const jumpRafRef = useRef(null); + const pinController = useMemo( + () => + createFramePinController( + () => { + const el = boundViewportRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, + (callback) => requestAnimationFrame(callback), + (handle) => cancelAnimationFrame(handle), + ), + [], + ); const cancelJumpAnimation = useCallback(() => { if (jumpRafRef.current !== null) { @@ -124,25 +136,28 @@ export function useScrollFollow(args: UseScrollFollowArgs): { const pinToBottom = useCallback(() => { // An instant pin supersedes any in-flight jump animation. cancelJumpAnimation(); - const el = boundViewportRef.current; - if (el) { - el.scrollTop = el.scrollHeight; - } - }, [cancelJumpAnimation]); + pinController.flush(); + }, [cancelJumpAnimation, pinController]); + + const schedulePinToBottom = useCallback(() => { + cancelJumpAnimation(); + pinController.schedule(); + }, [cancelJumpAnimation, pinController]); const dispatch = useCallback( - (event: FollowEvent) => { + (event: FollowEvent, pinMode: "immediate" | "frame" = "immediate") => { const wasFollowing = stateRef.current.following; const step = reduceFollowEvent(stateRef.current, event, configRef.current); stateRef.current = step.state; if (step.pin) { - pinToBottom(); + if (pinMode === "frame") schedulePinToBottom(); + else pinToBottom(); } if (step.state.following !== wasFollowing) { setFollowing(step.state.following); } }, - [pinToBottom], + [pinToBottom, schedulePinToBottom], ); const stickToBottom = useCallback(() => { @@ -350,7 +365,7 @@ export function useScrollFollow(args: UseScrollFollowArgs): { typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { - dispatch({ type: "contentGrowth", gap: getGap() }); + dispatch({ type: "contentGrowth", gap: getGap() }, "frame"); }); resizeObserver?.observe(viewport); if (growthTarget instanceof Element) { @@ -372,6 +387,7 @@ export function useScrollFollow(args: UseScrollFollowArgs): { } document.removeEventListener("visibilitychange", handleVisibilityChange); resizeObserver?.disconnect(); + pinController.cancel(); cancelJumpAnimation(); boundViewportRef.current = null; }; @@ -382,6 +398,7 @@ export function useScrollFollow(args: UseScrollFollowArgs): { enabled, listenerRoot, pinToBottom, + pinController, trackKeys, viewport, ]); diff --git a/crates/agent-gui/src/lib/chat/thinkingOverlayModel.ts b/crates/agent-gui/src/lib/chat/thinkingOverlayModel.ts new file mode 100644 index 000000000..9afe7ab3f --- /dev/null +++ b/crates/agent-gui/src/lib/chat/thinkingOverlayModel.ts @@ -0,0 +1,59 @@ +export type ThinkingOverlayRect = { + left: number; + right: number; + top: number; + bottom: number; + width: number; + height: number; +}; + +export type ThinkingOverlayViewport = { width: number; height: number }; + +export type ThinkingOverlayPlacement = { + side: "above" | "below"; + left: number; + width: number; + maxHeight: number; + top?: number; + bottom?: number; +}; + +const VIEWPORT_MARGIN_PX = 12; +const OVERLAY_GAP_PX = 8; +const MAX_OVERLAY_WIDTH_PX = 640; +const MIN_PREFERRED_HEIGHT_PX = 180; + +export function resolveThinkingOverlayPlacement( + trigger: ThinkingOverlayRect, + viewport: ThinkingOverlayViewport, +): ThinkingOverlayPlacement { + const viewportWidth = Math.max(1, viewport.width); + const horizontalMargin = Math.min(VIEWPORT_MARGIN_PX, Math.max(0, (viewportWidth - 1) / 2)); + const availableWidth = Math.max(1, viewportWidth - horizontalMargin * 2); + const width = Math.min(MAX_OVERLAY_WIDTH_PX, availableWidth); + const centeredLeft = trigger.left + (trigger.width - width) / 2; + const left = Math.min( + Math.max(horizontalMargin, centeredLeft), + Math.max(horizontalMargin, viewportWidth - horizontalMargin - width), + ); + const above = Math.max(0, trigger.top - OVERLAY_GAP_PX - VIEWPORT_MARGIN_PX); + const below = Math.max(0, viewport.height - trigger.bottom - OVERLAY_GAP_PX - VIEWPORT_MARGIN_PX); + const side = above >= MIN_PREFERRED_HEIGHT_PX || above >= below ? "above" : "below"; + + if (side === "above") { + return { + side, + left, + width, + maxHeight: above, + bottom: viewport.height - trigger.top + OVERLAY_GAP_PX, + }; + } + return { + side, + left, + width, + maxHeight: below, + top: trigger.bottom + OVERLAY_GAP_PX, + }; +} diff --git a/crates/agent-gui/src/lib/transcript-virtual/liveScrollAdjustPolicy.ts b/crates/agent-gui/src/lib/transcript-virtual/liveScrollAdjustPolicy.ts index d2ad8f63e..a03eb7864 100644 --- a/crates/agent-gui/src/lib/transcript-virtual/liveScrollAdjustPolicy.ts +++ b/crates/agent-gui/src/lib/transcript-virtual/liveScrollAdjustPolicy.ts @@ -3,10 +3,9 @@ import type { VirtualItem, Virtualizer } from "@tanstack/react-virtual"; // Resize-compensation policy for the transcript virtualizer (virtual-core // 3.17.x semantics). // -// With `anchorTo: 'end'`, virtual-core handles the bottom-pinned case itself: -// while the viewport is virtually at the end, `resizeItem` compensates by the -// total-size delta and this predicate's verdict is ignored. This policy -// therefore only governs the detached reader. +// `useScrollFollow` is the sole owner of live bottom pinning. While following, +// this predicate rejects every virtualizer resize correction so one content +// growth batch cannot first move by an estimate delta and then pin again. // // It replicates the upstream default and carves out exactly one case the // default gets wrong: the live streaming row grown taller than the viewport. @@ -21,8 +20,7 @@ import type { VirtualItem, Virtualizer } from "@tanstack/react-virtual"; // measurement always compensates (the estimate→actual delta must land // regardless of scroll direction) and a re-measurement is skipped during // backward scroll (the upstream "items jump while scrolling up" fix); -// - while following, the compensation cooperates with the scroll-follow pin, -// so it stays on; +// - while following, all compensation is delegated to scroll-follow; // - live-row shrinks (delta < 0, e.g. a thinking block collapsing near the // row's top) keep compensating so content under the reader stays put. export type LiveRowScrollAdjustPolicyArgs = { @@ -45,6 +43,9 @@ export function createLiveRowScrollAdjustPolicy< ) => boolean { const { getLiveStartIndex, isFollowing } = args; return (item, delta, instance) => { + if (isFollowing()) { + return false; + } // Un-echoed scroll writes accumulate in a private field until the next // scroll event; the upstream default folds them into the comparison, so // mirror that (fall back to 0 if the field ever disappears). @@ -70,8 +71,7 @@ export function createLiveRowScrollAdjustPolicy< liveStartIndex >= 0 && item.index >= liveStartIndex && delta > 0 && - item.end > viewportTop && - !isFollowing() + item.end > viewportTop ) { return false; } diff --git a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx index 85236e056..53412de59 100644 --- a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx +++ b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx @@ -38,36 +38,13 @@ export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: { const isVibingStatus = toolStatus === VIBING_STATUS; let status: ReactNode = null; - if (row.mutable && unit.kind === "placeholder") { - if (unit.showFallbackStatus) { - status = isCompactionRunning ? ( - - ) : isVibingStatus || !toolStatus ? ( - - ) : ( - {toolStatus} - ); - } else if (toolStatus) { - status = isCompactionRunning ? ( - - ) : isVibingStatus ? ( - - ) : ( - {toolStatus} - ); - } - } else if ( - row.mutable && - unit.kind === "block" && - toolStatus && - (!unit.hasRunningToolCall || isCompactionRunning || isVibingStatus) - ) { + if (unit.kind === "status") { status = isCompactionRunning ? ( - - ) : isVibingStatus ? ( - + + ) : isVibingStatus || !toolStatus ? ( + ) : ( - {toolStatus} + {toolStatus} ); } @@ -80,14 +57,10 @@ export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: { )}
- {status ?
{status}
: null} + {status ?
{status}
: null} {row.mutable && retryAttempts && retryAttempts.length > 0 ? ( @@ -97,7 +70,6 @@ export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: { void; -}) { - const hasText = /\S/.test(text || ""); - const { t } = useLocale(); - const [isOpen, setIsOpen] = useState(typeof open === "boolean" ? open : false); - const userInteractedRef = useRef(false); - useEffect(() => { - if (!userInteractedRef.current && typeof open === "boolean") { - setIsOpen(open); - } - }, [open]); - - if (!hasText) return null; - - return ( -
- - - {() => ( -
- -
- )} -
-
- ); -}); - export const RetryDetailsBlock = memo(function RetryDetailsBlock({ attempts, }: { @@ -132,7 +64,6 @@ export const RetryDetailsBlock = memo(function RetryDetailsBlock({ export const RoundBlockContent = memo(function RoundBlockContent(props: { block: GroupedRoundBlock; isLive: boolean; - isMutable: boolean; renderMode: "streaming" | "static"; runningToolCallIds: string[]; thinkingOpen: boolean; @@ -144,7 +75,6 @@ export const RoundBlockContent = memo(function RoundBlockContent(props: { const { block, isLive, - isMutable, renderMode, runningToolCallIds, thinkingOpen, @@ -158,9 +88,8 @@ export const RoundBlockContent = memo(function RoundBlockContent(props: { if (block.kind === "thinking") { const isRunning = isLive && thinkingOpen && isLatestThinking; content = ( - diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/StatusText.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/StatusText.tsx index f06adc85f..4546a4002 100644 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/StatusText.tsx +++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/StatusText.tsx @@ -28,7 +28,7 @@ export function AssistantStatus({ @@ -39,7 +39,9 @@ export function AssistantStatus({ iconClassName, )} /> - {children} + + {children} + ); } diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolTraceGroup.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolTraceGroup.tsx index 37d546a50..2e0c0e40e 100644 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolTraceGroup.tsx +++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolTraceGroup.tsx @@ -36,6 +36,17 @@ function ToolTraceGroupInner(props: { const ToolIcon = allBash ? Terminal : meta.Icon; const [open, setOpen] = useState(false); + if (items.length === 1) { + const item = items[0]; + return item ? ( + + ) : null; + } + const statusLabel = counts.failed > 0 ? `${counts.failed} ${t("chat.tool.failed")}` diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/assistantBubbleUtils.ts b/crates/agent-gui/src/pages/chat/components/assistant-bubble/assistantBubbleUtils.ts index c7caf6562..2dc0cec1f 100644 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/assistantBubbleUtils.ts +++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/assistantBubbleUtils.ts @@ -277,23 +277,14 @@ export function groupRoundBlocks(blocks: UiRound["blocks"]): GroupedRoundBlock[] const flushPendingTools = () => { if (pendingTools.length === 0) return; - if (pendingTools.length === 1) { - const item = pendingTools[0]; - groupedBlocks.push({ - kind: "tool", - key: `tool-${getToolTraceKey(item, pendingStartIndex)}`, - item, - }); - } else { - groupedBlocks.push({ - kind: "toolGroup", - // Anchored to the group's start only: appending tools to a streaming - // group must keep the key stable, or the remount would wipe the - // user's manual expand/collapse state mid-run. - key: `tool-group-${pendingStartIndex}-${getToolTraceKey(pendingTools[0], pendingStartIndex)}`, - items: pendingTools, - }); - } + groupedBlocks.push({ + kind: "toolGroup", + // The wrapper exists from the first ordinary tool onward. Appending a + // second tool therefore updates one activity in place instead of + // replacing a `tool` row with a differently keyed `toolGroup` row. + key: `tool-group-${getToolTraceKey(pendingTools[0], pendingStartIndex)}`, + items: pendingTools, + }); pendingTools = []; }; diff --git a/crates/agent-gui/src/pages/chat/transcript/AssistantActivityRow.tsx b/crates/agent-gui/src/pages/chat/transcript/AssistantActivityRow.tsx new file mode 100644 index 000000000..1390aafb8 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/transcript/AssistantActivityRow.tsx @@ -0,0 +1,65 @@ +import { memo } from "react"; + +import type { ChatFileLink } from "../../../lib/chat/chatFileLinks"; +import type { HistoryMessageRef } from "../../../lib/chat/conversation/conversationState"; +import type { RetryAttemptRecord } from "../../../lib/chat/conversation/liveTranscriptStore"; +import type { PendingUploadedFile } from "../../../lib/chat/messages/uploadedFiles"; +import { AssistantRenderUnit } from "./AssistantRenderUnit"; +import type { AssistantActivityRow as AssistantActivityRowModel } from "./rowModel"; + +export const AssistantActivityRow = memo(function AssistantActivityRow(props: { + row: AssistantActivityRowModel; + showUsage?: boolean; + usageContextWindow?: number; + isAgentMode: boolean; + isCompactionRunning: boolean; + toolStatus: string | null; + retryAttempts?: RetryAttemptRecord[]; + workdir?: string; + onOpenFileLink?: (link: ChatFileLink) => void; + onResendFromEdit: ( + messageRef: HistoryMessageRef, + text: string, + attachments: PendingUploadedFile[], + ) => void; + onBranchConversation?: (messageRef: HistoryMessageRef) => void; +}) { + const { + row, + showUsage, + usageContextWindow, + isAgentMode, + isCompactionRunning, + toolStatus, + retryAttempts, + workdir, + onOpenFileLink, + onResendFromEdit, + onBranchConversation, + } = props; + + return ( +
+ {row.units.map((unit, index) => ( +
+ + {unit.gapAfter > 0 && index < row.units.length - 1 ? ( + + ))} +
+ ); +}); diff --git a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx index bf3fb1335..32070a9fd 100644 --- a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx @@ -317,6 +317,7 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript scrollViewport={scrollViewport} layoutWidth={contentWidth} isViewportFollowing={scrollFollowHandle.isFollowing} + viewportFollowing={following} isSending={isSending} isAgentMode={isAgentMode} isCompactionRunning={isCompactionRunning} diff --git a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx index 6f775dc14..2f1ba1e8c 100644 --- a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx @@ -36,12 +36,13 @@ import { buildTranscriptLayoutKey, createTranscriptMeasurementsLru, } from "../../../lib/transcript-virtual/measurementsLru"; +import { AssistantActivityRow } from "./AssistantActivityRow"; import { AssistantRenderUnit } from "./AssistantRenderUnit"; import { extractRenderUnitRange } from "./renderUnitRangeExtractor"; import { createTranscriptRowModel } from "./rowModel"; import { UserMessageRow } from "./UserMessageRow"; -const TRANSCRIPT_MEASUREMENT_LAYOUT_VERSION = "assistant-units-v1"; +const TRANSCRIPT_MEASUREMENT_LAYOUT_VERSION = "assistant-activity-v2"; function buildVersionedTranscriptLayoutKey(viewportWidth: number, contentWidth: number) { const layoutKey = buildTranscriptLayoutKey(viewportWidth, contentWidth); @@ -111,6 +112,7 @@ export type TranscriptListProps = { // Whether the scroll-follow engine is attached to the bottom; gates the // virtualizer's resize-compensation carve-out for live-row growth. isViewportFollowing?: () => boolean; + viewportFollowing: boolean; isSending: boolean; isAgentMode: boolean; isCompactionRunning: boolean; @@ -137,8 +139,8 @@ export type TranscriptListProps = { }; // The whole transcript lives in one virtualized container. Assistant replies -// are block-level render units, so a long reply no longer becomes one giant -// row; only its mutable live tail stays force-mounted. +// are block-level render units. The currently active reply is one stable outer +// activity row; static history keeps block-level virtualization. export const TranscriptList = memo(function TranscriptList(props: TranscriptListProps) { const { conversationId, @@ -147,6 +149,7 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList scrollViewport, layoutWidth, isViewportFollowing, + viewportFollowing, isSending, isAgentMode, isCompactionRunning, @@ -282,23 +285,18 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList initialMeasurementsCache, directDomUpdates: true, directDomUpdatesMode: "transform", - // End-anchored: while the viewport sits within the threshold of the end, - // growth of the last row (streaming) compensates by the total-size delta - // upstream, and estimate→measure corrections keep the bottom pinned. The - // threshold matches scrollFollowCore's BOTTOM_ATTACH_THRESHOLD_PX so both - // engines agree on what "at the bottom" means. followOnAppend stays off: - // its DOM-distance re-follow would conflict with the follow reducer's - // "shrink clamps never re-attach" contract — appends while following are - // already pinned by the reducer. - anchorTo: "end", + // End anchoring is enabled only for a detached reader so keyed prepends + // preserve the visible row. While following, start anchoring disables the + // virtualizer's bottom correction and leaves live growth to useScrollFollow. + anchorTo: viewportFollowing ? "start" : "end", scrollEndThreshold: 8, rangeExtractor: extractVirtualRange, }); // TanStack exposes the resize-compensation predicate as an instance field, // not an option; reassigning per render keeps the closure's inputs current. - // It only governs the detached reader — while virtually at the end, the - // upstream end-anchor compensation takes priority over this predicate. + // While following it rejects every virtualizer correction; while detached + // it retains estimate/measurement anchoring for rows above the viewport. virtualizer.shouldAdjustScrollPositionOnItemSizeChange = createLiveRowScrollAdjustPolicy({ getLiveStartIndex: () => liveStartIndexRef.current, isFollowing: () => isViewportFollowing?.() ?? false, @@ -514,6 +512,24 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList />
); + } else if (row.kind === "assistant-activity") { + body = ( +
+ +
+ ); } else { body = (
@@ -537,6 +553,7 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList return (
total + unit.estimate + (index < lastIndex ? unit.gapAfter : 0), + 0, + ), + renderCost: Math.min( + 32, + Math.max( + 1, + units.reduce((total, unit) => total + unit.renderCost, 0), + ), + ), + gapAfter: units.at(-1)?.gapAfter ?? TRANSCRIPT_ROW_GAP_PX, + anchorUserKey: units[0]?.anchorUserKey ?? null, + live: units.some((unit) => unit.live), + units, + }; +} + type BuildAssistantUnitsInput = { replyKey: string; live: boolean; @@ -296,7 +340,7 @@ function buildAssistantUnits(input: BuildAssistantUnitsInput): AssistantUnitRow[ const isAborted = rounds.some((round) => round.meta?.stopReason === "aborted"); const rows: AssistantUnitRow[] = []; - rounds.forEach((round, roundIndex) => { + rounds.forEach((round) => { const groupedBlocks = groupRoundBlocks(round.blocks).filter((block) => isVisibleGroupedBlock(block, latestTodoItem), ); @@ -340,33 +384,23 @@ function buildAssistantUnits(input: BuildAssistantUnitsInput): AssistantUnitRow[ }, }); }); + }); - if (live && roundIndex === rounds.length - 1 && groupedBlocks.length === 0) { - rows.push({ - kind: "assistant-unit", - key: `${replyKey}:round:${round.key}:placeholder`, - replyKey, - estimate: 64, - renderCost: 1, - gapAfter: TRANSCRIPT_ROW_GAP_PX, - anchorUserKey, - live: true, + if (live) { + const contentTailIndex = rows.length - 1; + const contentTail = rows[contentTailIndex]; + if (contentTail) { + rows[contentTailIndex] = { + ...contentTail, + gapAfter: ASSISTANT_UNIT_GAP_PX, mutable: true, - renderMode, - compacted, - showAvatar: rows.length === 0, - isAborted, - unit: { kind: "placeholder", showFallbackStatus: false }, - }); + }; } - }); - - if (live && rows.length === 0) { rows.push({ kind: "assistant-unit", - key: `${replyKey}:placeholder`, + key: `${replyKey}:footer`, replyKey, - estimate: 64, + estimate: rows.length === 0 ? 64 : 32, renderCost: 1, gapAfter: TRANSCRIPT_ROW_GAP_PX, anchorUserKey, @@ -374,21 +408,10 @@ function buildAssistantUnits(input: BuildAssistantUnitsInput): AssistantUnitRow[ mutable: true, renderMode, compacted, - showAvatar: true, + showAvatar: rows.length === 0, isAborted, - unit: { kind: "placeholder", showFallbackStatus: true }, + unit: { kind: "status" }, }); - } else if (live) { - const tailIndex = rows.length - 1; - const tail = rows[tailIndex]; - if (tail) { - rows[tailIndex] = { - ...tail, - estimate: tail.estimate + 36, - gapAfter: TRANSCRIPT_ROW_GAP_PX, - mutable: true, - }; - } } else { const changedFilesCandidate = hasChangedFilesCandidate(rounds); const contentTailIndex = rows.length - 1; @@ -467,8 +490,11 @@ export function createTranscriptRowModel(options?: TranscriptRowModelOptions): T replyKey: string; historyLenAtStart: number; liveUnitCache: Map; + lastLiveUnits: AssistantUnitRow[]; + settlingUnits: AssistantUnitRow[] | null; } | null = null; let pendingSettle: { replyKey: string; historyLenAtStart: number } | null = null; + let deferredSettles: { replyKey: string; historyLenAtStart: number }[] = []; let draftRoundCache: { text: string; round: LiveRound } | null = null; const reset = () => { @@ -480,6 +506,7 @@ export function createTranscriptRowModel(options?: TranscriptRowModelOptions): T turnSeq = 0; activeTurn = null; pendingSettle = null; + deferredSettles = []; draftRoundCache = null; }; @@ -505,7 +532,7 @@ export function createTranscriptRowModel(options?: TranscriptRowModelOptions): T ) => { for (let index = historyItems.length - 1; index >= turn.historyLenAtStart; index -= 1) { const item = historyItems[index]; - if (item?.kind === "assistant") { + if (item?.kind === "assistant" && !streamOrigins.has(item.key)) { streamOrigins.set(item.key, turn.replyKey); if (rowCache.has(item)) { rowCache.delete(item); @@ -554,7 +581,7 @@ export function createTranscriptRowModel(options?: TranscriptRowModelOptions): T ]; } else { const originKey = streamOrigins.get(item.key); - rows = buildAssistantUnits({ + const assistantUnits = buildAssistantUnits({ replyKey: originKey ?? item.key, live: false, renderMode: originKey ? "streaming" : "static", @@ -565,6 +592,7 @@ export function createTranscriptRowModel(options?: TranscriptRowModelOptions): T retryTarget, anchorUserKey, }); + rows = originKey ? [buildAssistantActivityRow(originKey, assistantUnits)] : assistantUnits; } rowCache.set(item, { anchorUserKey, retryTarget, rows }); return rows; @@ -578,25 +606,44 @@ export function createTranscriptRowModel(options?: TranscriptRowModelOptions): T const isInitialBuild = !hasBuilt; hasBuilt = true; - if (liveTailVisible && !activeTurn) { + if (liveTailVisible && pendingSettle && activeTurn) { + if (!adoptSettledTwin(historyItems, pendingSettle)) { + deferredSettles.push(pendingSettle); + } + pendingSettle = null; + activeTurn = { + replyKey: `live-turn-${++turnSeq}`, + historyLenAtStart: historyItems.length, + liveUnitCache: new Map(), + lastLiveUnits: [], + settlingUnits: null, + }; + } else if (liveTailVisible && !activeTurn) { pendingSettle = null; activeTurn = { replyKey: `live-turn-${++turnSeq}`, historyLenAtStart: historyItems.length, liveUnitCache: new Map(), + lastLiveUnits: [], + settlingUnits: null, }; } else if (!liveTailVisible && activeTurn) { - if (!adoptSettledTwin(historyItems, activeTurn)) { + const adopted = adoptSettledTwin(historyItems, activeTurn); + if (!adopted) { pendingSettle = { replyKey: activeTurn.replyKey, historyLenAtStart: activeTurn.historyLenAtStart, }; } - activeTurn = null; + if (adopted) activeTurn = null; } else if (!liveTailVisible && pendingSettle) { if (adoptSettledTwin(historyItems, pendingSettle)) pendingSettle = null; } + if (deferredSettles.length > 0) { + deferredSettles = deferredSettles.filter((turn) => !adoptSettledTwin(historyItems, turn)); + } + const bornKeys: string[] = []; const trackBirth = (key: string) => { if (!knownKeys.has(key)) { @@ -622,27 +669,42 @@ export function createTranscriptRowModel(options?: TranscriptRowModelOptions): T let rows = historyRows; let liveStartIndex = -1; - if (liveTailVisible && activeTurn) { - const rounds: (UiRound | LiveRound)[] = - live.liveRounds.length > 0 - ? live.liveRounds - : live.draftAssistantText - ? [draftRound(live.draftAssistantText)] - : []; - const liveRows = buildAssistantUnits({ - replyKey: activeTurn.replyKey, - live: true, - renderMode: "streaming", - rounds, - compacted: false, - replyText: "", - retryTarget: null, - anchorUserKey: historyRows.at(-1)?.anchorUserKey ?? null, - liveUnitCache: activeTurn.liveUnitCache, - }); - rows = [...historyRows, ...liveRows]; + if ((liveTailVisible || pendingSettle) && activeTurn) { + let liveUnits = activeTurn.lastLiveUnits; + if (liveTailVisible) { + const rounds: (UiRound | LiveRound)[] = + live.liveRounds.length > 0 + ? live.liveRounds + : live.draftAssistantText + ? [draftRound(live.draftAssistantText)] + : []; + liveUnits = buildAssistantUnits({ + replyKey: activeTurn.replyKey, + live: true, + renderMode: "streaming", + rounds, + compacted: false, + replyText: "", + retryTarget: null, + anchorUserKey: historyRows.at(-1)?.anchorUserKey ?? null, + liveUnitCache: activeTurn.liveUnitCache, + }); + activeTurn.lastLiveUnits = liveUnits; + activeTurn.settlingUnits = null; + } else { + if (!activeTurn.settlingUnits) { + activeTurn.settlingUnits = activeTurn.lastLiveUnits.map((row) => ({ + ...row, + live: false, + mutable: false, + })); + } + liveUnits = activeTurn.settlingUnits; + } + const liveActivity = buildAssistantActivityRow(activeTurn.replyKey, liveUnits); + rows = [...historyRows, liveActivity]; liveStartIndex = rows.length - 1; - for (const row of liveRows) trackBirth(row.key); + trackBirth(liveActivity.key); } if (bornKeys.length > 0 || isInitialBuild) { diff --git a/crates/agent-gui/test/chat/block-round-keys.test.mjs b/crates/agent-gui/test/chat/block-round-keys.test.mjs index e4f91dade..3ec6eb693 100644 --- a/crates/agent-gui/test/chat/block-round-keys.test.mjs +++ b/crates/agent-gui/test/chat/block-round-keys.test.mjs @@ -93,6 +93,64 @@ test("groupRoundBlocks keys survive a block being inserted before them", () => { assert.equal(keysAfter[0], "thinking-1"); }); +test("ordinary tool activity keeps one group identity as later tools append", () => { + const tool = (id) => ({ + kind: "tool", + item: { toolCall: { type: "toolCall", id, name: "Bash", arguments: {} } }, + }); + const first = bubbleUtils.groupRoundBlocks([tool("call-1")]); + const appended = bubbleUtils.groupRoundBlocks([tool("call-1"), tool("call-2")]); + + assert.equal(first.length, 1); + assert.equal(appended.length, 1); + assert.equal(first[0].kind, "toolGroup"); + assert.equal(appended[0].kind, "toolGroup"); + assert.equal(appended[0].key, first[0].key); + assert.deepEqual( + appended[0].items.map((item) => item.toolCall.id), + ["call-1", "call-2"], + ); +}); + +test("special tool result updates preserve their direct activity identity", () => { + for (const name of ["TodoWrite", "AskUserQuestion", "Image", "Agent"]) { + const pendingItem = { + toolCall: { type: "toolCall", id: `call-${name}`, name, arguments: {} }, + }; + const settledItem = { + ...pendingItem, + toolResult: { + role: "toolResult", + toolCallId: `call-${name}`, + content: [], + isError: name === "Image", + }, + }; + const pending = bubbleUtils.groupRoundBlocks([{ kind: "tool", item: pendingItem }]); + const settled = bubbleUtils.groupRoundBlocks([{ kind: "tool", item: settledItem }]); + + assert.equal(pending.length, 1, name); + assert.equal(settled.length, 1, name); + assert.equal(pending[0].kind, "tool", name); + assert.equal(settled[0].kind, "tool", name); + assert.equal(settled[0].key, pending[0].key, name); + } +}); + +test("hosted search activity keeps one group identity as later searches append", () => { + const first = bubbleUtils.groupRoundBlocks([ + { kind: "hostedSearch", item: { id: "search-1" } }, + ]); + const appended = bubbleUtils.groupRoundBlocks([ + { kind: "hostedSearch", item: { id: "search-1" } }, + { kind: "hostedSearch", item: { id: "search-2" } }, + ]); + + assert.equal(first[0].kind, "hostedSearchGroup"); + assert.equal(appended[0].kind, "hostedSearchGroup"); + assert.equal(appended[0].key, first[0].key); +}); + // --------------------------------------------------------------------------- // Round keys: history rounds are r; rebuilds are deterministic diff --git a/crates/agent-gui/test/chat/frame-pin-controller.test.mjs b/crates/agent-gui/test/chat/frame-pin-controller.test.mjs new file mode 100644 index 000000000..09998c2b4 --- /dev/null +++ b/crates/agent-gui/test/chat/frame-pin-controller.test.mjs @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const { createFramePinController } = createTsModuleLoader().loadModule( + "src/lib/chat-scroll/framePinController.ts", +); + +test("coalesces repeated live growth into one pin per frame", () => { + const callbacks = []; + let writes = 0; + const controller = createFramePinController( + () => { + writes += 1; + }, + (callback) => { + callbacks.push(callback); + return callbacks.length; + }, + () => {}, + ); + + controller.schedule(); + controller.schedule(); + controller.schedule(); + assert.equal(callbacks.length, 1); + assert.equal(writes, 0); + callbacks.shift()(); + assert.equal(writes, 1); + + controller.schedule(); + assert.equal(callbacks.length, 1); +}); + +test("an immediate pin cancels the queued frame and writes once", () => { + const callbacks = []; + const cancelled = []; + let writes = 0; + const controller = createFramePinController( + () => { + writes += 1; + }, + (callback) => { + callbacks.push(callback); + return callbacks.length; + }, + (handle) => cancelled.push(handle), + ); + controller.schedule(); + controller.flush(); + assert.deepEqual(cancelled, [1]); + assert.equal(writes, 1); +}); diff --git a/crates/agent-gui/test/chat/live-markdown-caret.test.mjs b/crates/agent-gui/test/chat/live-markdown-caret.test.mjs new file mode 100644 index 000000000..a0d6adfee --- /dev/null +++ b/crates/agent-gui/test/chat/live-markdown-caret.test.mjs @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; + +const roundContentSource = fs.readFileSync( + new URL("../../src/pages/chat/components/assistant-bubble/RoundContent.tsx", import.meta.url), + "utf8", +); + +test("live assistant text does not render a trailing caret row", () => { + assert.doesNotMatch(roundContentSource, /showCaret=/); +}); diff --git a/crates/agent-gui/test/chat/live-scroll-adjust-policy.test.mjs b/crates/agent-gui/test/chat/live-scroll-adjust-policy.test.mjs index d04714e90..9b97266ac 100644 --- a/crates/agent-gui/test/chat/live-scroll-adjust-policy.test.mjs +++ b/crates/agent-gui/test/chat/live-scroll-adjust-policy.test.mjs @@ -75,10 +75,11 @@ test("detached reader inside the growing live row is left alone (streaming creep assert.equal(policy(item, 60, makeInstance({ scrollOffset: 3000, measuredKeys: [5] })), false); }); -test("the same live-row growth while following keeps compensating (pin assist)", () => { +test("following delegates every resize correction to the scroll-follow owner", () => { const policy = makePolicy({ liveStartIndex: 5, following: true }); const item = makeItem({ index: 5, start: 400, size: 5000 }); - assert.equal(policy(item, 60, makeInstance({ scrollOffset: 3000, measuredKeys: [5] })), true); + assert.equal(policy(item, 60, makeInstance({ scrollOffset: 3000, measuredKeys: [5] })), false); + assert.equal(policy(item, -80, makeInstance({ scrollOffset: 3000, measuredKeys: [5] })), false); }); test("live-row shrink keeps compensating so content under the reader stays put", () => { diff --git a/crates/agent-gui/test/chat/live-status-width.test.mjs b/crates/agent-gui/test/chat/live-status-width.test.mjs new file mode 100644 index 000000000..047ade658 --- /dev/null +++ b/crates/agent-gui/test/chat/live-status-width.test.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; + +const activitySource = fs.readFileSync( + new URL("../../src/pages/chat/transcript/AssistantActivityRow.tsx", import.meta.url), + "utf8", +); +const bubbleSource = fs.readFileSync( + new URL("../../src/pages/chat/components/AssistantBubble.tsx", import.meta.url), + "utf8", +); + +test("desktop live status cannot widen the transcript", () => { + assert.match(activitySource, /min-w-0 w-full max-w-full/); + assert.match(bubbleSource, /min-w-0 max-w-full overflow-hidden py-1\.5/); + assert.match(bubbleSource, / { + const placement = resolveThinkingOverlayPlacement( + { left: 200, right: 700, top: 500, bottom: 532, width: 500, height: 32 }, + { width: 1200, height: 800 }, + ); + assert.equal(placement.side, "above"); + assert.equal(placement.bottom, 308); + assert.ok(placement.maxHeight >= 180); +}); + +test("falls below on a short viewport and clamps narrow widths", () => { + const placement = resolveThinkingOverlayPlacement( + { left: 8, right: 312, top: 60, bottom: 92, width: 304, height: 32 }, + { width: 320, height: 480 }, + ); + assert.equal(placement.side, "below"); + assert.equal(placement.left, 12); + assert.equal(placement.width, 296); +}); + +test("keeps a renderable overlay inside an extremely narrow viewport", () => { + const placement = resolveThinkingOverlayPlacement( + { left: 0, right: 8, top: 60, bottom: 92, width: 8, height: 32 }, + { width: 8, height: 480 }, + ); + assert.equal(placement.left, 3.5); + assert.equal(placement.width, 1); + assert.ok(placement.left + placement.width <= 8); +}); + +test("thinking details use a portal overlay instead of inline collapse", () => { + assert.match(componentSource, /createPortal/); + assert.match(componentSource, /role="dialog"/); + assert.match(componentSource, /className="fixed/); + assert.match(componentSource, /event\.key !== "Escape"/); + assert.doesNotMatch(componentSource, /LazyCollapse/); +}); diff --git a/crates/agent-gui/test/chat/transcript-row-model.test.mjs b/crates/agent-gui/test/chat/transcript-row-model.test.mjs index 63725eb05..7cf0bdb1b 100644 --- a/crates/agent-gui/test/chat/transcript-row-model.test.mjs +++ b/crates/agent-gui/test/chat/transcript-row-model.test.mjs @@ -52,15 +52,15 @@ function round(key, text) { } function blockRows(snapshot) { - return snapshot.rows.filter( - (row) => row.kind === "assistant-unit" && row.unit.kind === "block", - ); + return snapshot.rows + .flatMap((row) => (row.kind === "assistant-activity" ? row.units : [row])) + .filter((row) => row.kind === "assistant-unit" && row.unit.kind === "block"); } function footerRows(snapshot) { - return snapshot.rows.filter( - (row) => row.kind === "assistant-unit" && row.unit.kind === "footer", - ); + return snapshot.rows + .flatMap((row) => (row.kind === "assistant-activity" ? row.units : [row])) + .filter((row) => row.kind === "assistant-unit" && row.unit.kind === "footer"); } const idleLive = { @@ -89,7 +89,7 @@ test("settling a live turn preserves every block-unit key and adds one footer un const settledHistory = [userItem("u1"), assistantItem("a1", [round("r1", "full reply")])]; const settled = model.build(settledHistory, idleLive); assert.equal(settled.liveStartIndex, -1); - assert.equal(settled.rows.length, 3); + assert.equal(settled.rows.length, 2); assert.equal(blockRows(settled)[0].key, liveBlockKey); assert.equal(blockRows(settled)[0].renderMode, "streaming"); assert.equal(footerRows(settled).length, 1); @@ -112,7 +112,13 @@ test("persist lag: block-unit aliases still land one build later", () => { }); const liveBlockKey = blockRows(streaming)[0].key; - assert.equal(model.build(history, idleLive).rows.length, 1); + const waitingForHistory = model.build(history, idleLive); + assert.equal(waitingForHistory.rows.length, 2, "the live activity must not disappear while persistence lags"); + assert.equal(blockRows(waitingForHistory)[0].key, liveBlockKey); + assert.equal(waitingForHistory.rows.at(-1).kind, "assistant-activity"); + assert.equal(waitingForHistory.rows.at(-1).live, false); + assert.equal(waitingForHistory.rows.at(-1).units.at(-1).unit.kind, "status"); + assert.equal("active" in waitingForHistory.rows.at(-1).units.at(-1).unit, false); const settled = model.build( [userItem("u1"), assistantItem("a1", [round("r1", "full reply")])], idleLive, @@ -128,15 +134,29 @@ test("a new turn supersedes an unresolved settle so aliases never cross turns", liveRounds: [{ ...round("r1", "x"), runningToolCallIds: [], thinkingOpen: false }], }; - model.build([userItem("u1")], sendingLive); + const firstStreaming = model.build([userItem("u1")], sendingLive); + const firstLiveBlockKey = blockRows(firstStreaming).at(-1).key; model.build([userItem("u1")], idleLive); const secondStreaming = model.build([userItem("u1"), userItem("u2")], sendingLive); const secondLiveBlockKey = blockRows(secondStreaming).at(-1).key; + const delayedFirstTwin = model.build( + [userItem("u1"), assistantItem("a1", [round("r1", "reply 1")]), userItem("u2")], + sendingLive, + ); + assert.equal(blockRows(delayedFirstTwin)[0].key, firstLiveBlockKey); + assert.equal(blockRows(delayedFirstTwin).at(-1).key, secondLiveBlockKey); + const settled = model.build( - [userItem("u1"), userItem("u2"), assistantItem("a2", [round("r1", "reply 2")])], + [ + userItem("u1"), + assistantItem("a1", [round("r1", "reply 1")]), + userItem("u2"), + assistantItem("a2", [round("r1", "reply 2")]), + ], idleLive, ); + assert.equal(blockRows(settled)[0].key, firstLiveBlockKey); assert.equal(blockRows(settled).at(-1).key, secondLiveBlockKey); }); @@ -235,7 +255,7 @@ test("a committed twin that races persistence is re-keyed at settle", () => { assert.equal(blockRows(racing)[0].key, "a1:round:r1:block:text-1"); const settled = model.build(midRun, idleLive); - assert.equal(settled.rows.length, 3); + assert.equal(settled.rows.length, 2); assert.equal(blockRows(settled)[0].key, liveBlockKey); }); @@ -255,19 +275,20 @@ test("terminal settlement removes the live tail before sending clears", () => { const committed = [userItem("u1"), assistantItem("a1", [round("r1", "full reply")])]; const finalizing = model.build(committed, { ...store.getSnapshot(), isSending: true }); - assert.equal(finalizing.rows.length, 3); + assert.equal(finalizing.rows.length, 2); assert.equal(finalizing.liveStartIndex, -1); assert.equal(blockRows(finalizing)[0].key, liveBlockKey); assert.equal(blockRows(finalizing)[0].live, false); const released = model.build(committed, { ...store.getSnapshot(), isSending: false }); - assert.equal(released.rows.length, 3); + assert.equal(released.rows.length, 2); store.reset(); const nextPending = model.build(committed, { ...store.getSnapshot(), isSending: true }); - assert.equal(nextPending.rows.length, 4); - assert.equal(nextPending.liveStartIndex, 3); - assert.equal(nextPending.rows[3].mutable, true); + assert.equal(nextPending.rows.length, 3); + assert.equal(nextPending.liveStartIndex, 2); + assert.equal(nextPending.rows[2].kind, "assistant-activity"); + assert.equal(nextPending.rows[2].units.at(-1).mutable, true); }); test("assistant rounds flatten into grouped top-level render units", () => { @@ -311,7 +332,7 @@ test("Markdown text blocks stay whole instead of being string-sliced", () => { assert.ok(blockRows(snapshot)[0].renderCost > 1); }); -test("only the mutable live tail is pinned while completed prefix units virtualize", () => { +test("one live activity is pinned while its completed prefix units keep stable keys", () => { const model = createTranscriptRowModel(); const liveRound = { round: 1, @@ -335,10 +356,133 @@ test("only the mutable live tail is pinned while completed prefix units virtuali units.map((row) => row.mutable), [false, false, true], ); - assert.equal(snapshot.liveStartIndex, snapshot.rows.indexOf(units[2])); + const activity = snapshot.rows.find((row) => row.kind === "assistant-activity"); + assert.ok(activity); + assert.deepEqual( + activity.units.filter((unit) => unit.unit.kind === "block").map((unit) => unit.key), + units.map((unit) => unit.key), + ); + assert.equal(activity.units.at(-1).unit.kind, "status"); + assert.equal(snapshot.liveStartIndex, snapshot.rows.indexOf(activity)); assert.equal(snapshot.liveStartIndex, snapshot.rows.length - 1); }); +test("the active assistant turn stays one outer activity row through growth and settlement", () => { + const model = createTranscriptRowModel(); + const history = [userItem("u1")]; + const firstRound = { + round: 1, + key: "r1", + blocks: [{ kind: "thinking", id: "thinking-1", text: "first thought" }], + runningToolCallIds: [], + thinkingOpen: true, + }; + const first = model.build(history, { + ...idleLive, + isSending: true, + liveRounds: [firstRound], + }); + const firstActivity = first.rows.find((row) => row.kind === "assistant-activity"); + assert.ok(firstActivity); + assert.equal(first.rows.filter((row) => row.kind === "assistant-activity").length, 1); + const stableStatusKey = firstActivity.units.at(-1).key; + assert.equal(firstActivity.units.at(-1).unit.kind, "status"); + + const toolItem = { + toolCall: { type: "toolCall", id: "call-1", name: "Bash", arguments: { command: "pwd" } }, + }; + const grownRound = { + ...firstRound, + blocks: [...firstRound.blocks, { kind: "tool", item: toolItem }], + runningToolCallIds: ["call-1"], + thinkingOpen: false, + }; + const grown = model.build(history, { + ...idleLive, + isSending: true, + liveRounds: [grownRound], + }); + const grownActivity = grown.rows.find((row) => row.kind === "assistant-activity"); + assert.ok(grownActivity); + assert.equal(grownActivity.key, firstActivity.key); + assert.equal(grown.liveStartIndex, grown.rows.indexOf(grownActivity)); + assert.equal(grownActivity.units.at(-1).key, stableStatusKey); + assert.equal(grownActivity.units.at(-1).unit.kind, "status"); + assert.deepEqual( + grownActivity.units + .filter((unit) => unit.unit.kind !== "status") + .slice(0, firstActivity.units.length - 1) + .map((unit) => unit.key), + firstActivity.units.filter((unit) => unit.unit.kind !== "status").map((unit) => unit.key), + ); + + const settledHistory = [ + userItem("u1"), + assistantItem("a1", [ + { round: grownRound.round, key: grownRound.key, blocks: grownRound.blocks }, + ]), + ]; + const settled = model.build(settledHistory, idleLive); + const settledActivity = settled.rows.find((row) => row.kind === "assistant-activity"); + assert.ok(settledActivity); + assert.equal(settledActivity.key, firstActivity.key); + assert.equal(settledActivity.units.at(-1).key, stableStatusKey); + assert.equal(settledActivity.units.at(-1).unit.kind, "footer"); + assert.deepEqual( + settledActivity.units + .filter((unit) => unit.unit.kind === "block") + .map((unit) => unit.key), + grownActivity.units.filter((unit) => unit.unit.kind === "block").map((unit) => unit.key), + ); +}); + +test("one outer activity row stays stable across one hundred appended tools", () => { + const model = createTranscriptRowModel(); + const history = [userItem("u1")]; + let outerKey = ""; + let toolActivityKey = ""; + + for (let count = 1; count <= 100; count += 1) { + const blocks = Array.from({ length: count }, (_, index) => ({ + kind: "tool", + item: { + toolCall: { + type: "toolCall", + id: `call-${index + 1}`, + name: "Bash", + arguments: { command: `Write-Output ${index + 1}` }, + }, + }, + })); + const snapshot = model.build(history, { + ...idleLive, + isSending: true, + liveRounds: [ + { + round: 1, + key: "r1", + blocks, + runningToolCallIds: [`call-${count}`], + thinkingOpen: false, + }, + ], + }); + const activity = snapshot.rows.find((row) => row.kind === "assistant-activity"); + assert.ok(activity); + const groupedTool = activity.units.find( + (unit) => unit.unit.kind === "block" && unit.unit.block.kind === "toolGroup", + ); + assert.ok(groupedTool); + if (count === 1) { + outerKey = activity.key; + toolActivityKey = groupedTool.key; + } else { + assert.equal(activity.key, outerKey); + assert.equal(groupedTool.key, toolActivityKey); + } + } +}); + test("assistant unit keys do not depend on the history-window-relative index", () => { const model = createTranscriptRowModel(); const assistant = assistantItem("assistant-stable", [round("r1", "reply")]); @@ -438,6 +582,8 @@ test("transcript virtualizer keeps scroll updates off the full React measurement assert.match(transcriptListSource, /estimateSize:\s*estimateRowSize/); assert.match(transcriptListSource, /getItemKey:\s*getRowKey/); assert.match(transcriptListSource, /rangeExtractor:\s*extractVirtualRange/); + assert.match(transcriptListSource, /anchorTo:\s*viewportFollowing \? "start" : "end"/); + assert.match(transcriptListSource, /data-row-key=\{row\.key\}/); assert.match(transcriptListSource, /directDomUpdates:\s*true/); assert.match(transcriptListSource, /directDomUpdatesMode:\s*"transform"/); assert.match(transcriptListSource, /ref=\{virtualizer\.containerRef\}/); diff --git a/docs/images/live-transcript-stability-webui.png b/docs/images/live-transcript-stability-webui.png new file mode 100644 index 000000000..db4639d7d Binary files /dev/null and b/docs/images/live-transcript-stability-webui.png differ diff --git a/docs/worklog/live-transcript-jitter.md b/docs/worklog/live-transcript-jitter.md new file mode 100644 index 000000000..50a482dcc --- /dev/null +++ b/docs/worklog/live-transcript-jitter.md @@ -0,0 +1,174 @@ +# Live transcript activity stability + +## Goal + +Keep the active assistant turn structurally stable while thinking, tools, tool results, and status updates stream into the desktop GUI and Gateway WebUI. Existing activity must update in place, bottom following must have one owner, and detached readers must keep their viewport anchor. + +## Baseline and isolation + +- Baseline: `upstream/main` at `849daf269762846557cc8243c5fe6e7fb155ed72`. +- Post-acceptance fetch on 2026-08-01 advanced `upstream/main` to `7de95a20bf93cfe026a57f6367c453e74a50acef` (two commits affecting only `.github/workflows/pr-governance.yml`). A read-only merge-tree check found no conflict; the accepted branch was intentionally not rebased or rewritten. +- Branch: `codex/fix-live-transcript-jitter`. +- Worktree: `D:\Documents\Projects\Web\LiveAgent\target\codex-live-transcript-jitter-worktree`. +- The main worktree and its existing `crates/agent-gui/src-tauri/Cargo.toml` edit remain untouched. +- The older `target\codex-transcript-order-scroll-jitter-worktree` is not reused or modified. + +## Confirmed causes + +1. A live thinking block automatically expands its Markdown body inline. When a tool starts, `thinkingOpen` closes it and the virtual row loses that height in one commit. +2. One ordinary tool is projected as `tool`, while a second consecutive tool replaces it with a differently keyed `toolGroup`, remounting the visible activity at the same logical position. +3. Desktop live content is split into several outer virtual rows; only the mutable tail is force-mounted. The active turn therefore changes the virtual row set as blocks arrive. +4. `anchorTo: "end"` in the transcript virtualizer and `useScrollFollow` both compensate the same bottom growth. Resize measurement and bottom pin writes can land sequentially and create a visible direction reversal. +5. Gateway already keeps one outer assistant row per turn, but shares the inline thinking expansion, tool grouping transition, end anchoring, and scroll-follow competition. +6. Runtime frame sampling exposed a second height oscillator after scroll ownership was unified: the live status footer was conditionally removed between tool phases, and long `正在执行…` text wrapped to two lines before returning to one-line `Vibing...`. Each cycle changed the measured height by about 15.2px, so the browser clamped upward before the next bottom pin. +7. Desktop settlement cleared the active activity before its persisted history twin was guaranteed to exist. A one-render persistence lag therefore removed the whole live row; a second turn could also discard the first turn's unresolved stream-origin alias. + +## Approved implementation direction + +- Project the active desktop assistant turn as one outer live activity row with stable identity; keep its child activity keys stable through settlement. +- Keep ordinary tool activity under one stable group identity from the first tool onward. A single tool may retain its direct visual treatment inside that stable wrapper. +- Render full thinking details in a fixed/absolute overlay. Opening, closing, and automatic running-state changes must not change transcript height. +- Make `useScrollFollow` the only writer for live bottom growth. The virtualizer may still measure and compensate detached/history readers, but must not perform end-anchor writes while following. +- Preserve Gateway-specific store architecture while applying the same activity and scroll invariants. + +## Implemented model and rendering decisions + +- Desktop live and stream-origin-settled replies now use one `assistantActivity` virtual row keyed by `${replyKey}:activity`. Child unit keys survive tool arrival, result updates, and settlement. +- Consecutive ordinary tools use a stable group keyed by the first tool from the first item onward. The singleton group keeps the existing single-tool visual treatment, avoiding a visible design change while preventing the one-to-many remount. +- `ThinkingActivity` is a props-only mirrored component. The compact row remains in flow; details render through a fixed portal and close on Escape, outside pointer, or focus departure. Placement is clamped for ordinary narrow windows and remains non-zero even for an extremely narrow synthetic viewport. +- Streaming growth is coalesced by `framePinController`, so repeated ResizeObserver notifications schedule at most one bottom write per animation frame. While following, virtualizer resize compensation is disabled and `anchorTo` is `start`; while detached, the existing virtualizer compensation path and `anchorTo: end` preserve history/prepend anchoring. +- Gateway keeps its existing one-row-per-turn store shape, but receives the same stable tool grouping, thinking overlay, frame pinning, and following/detached anchoring policy. +- Both clients keep one compact status tail from the start of streaming. Status text is single-line and truncated, so tool summaries cannot change the footer height. Desktop reuses the same `${replyKey}:footer` key when the settled footer takes over. +- Desktop retains cached activity units while persistence catches up. The same visible Vibing status and footer key remain until the persisted twin takes over, delayed history twins recover their original stream identity, and unresolved aliases survive a newer live turn until hydration can match them. +- Final visual feedback removed Streamdown's trailing live-text caret in both clients. The caret could render as a standalone white bar and reserve an otherwise empty line between a completed text block and the next tool; activity progress is already communicated by the stable status tail, so the duplicate cue is no longer emitted. +- Status width is now bounded through the complete flex chain: the desktop activity row occupies the transcript width, status wrappers allow shrinking and clip overflow, and the status text itself owns the ellipsis. WebUI applies the same footer constraint, so long tool summaries cannot widen either transcript. + +## TodoWrite compatibility + +The task-progress PR worktree is based on a different stack and changes GUI `rowModel.ts` to hide all `TodoWrite` blocks. This task remains independent of that PR. A read-only `git apply --check` of this task's relevant projection patches against `codex/feat-task-progress-indicator-stacked` at `7fb096839d5fd423a981f384227bdd3a08876515` passed. No TodoWrite implementation was copied and no Git dependency was introduced. + +## Verification status + +- Git/worktree gate: passed. +- Architecture and prior task-progress worklog review: complete. +- Parallel read-only GUI, WebUI, scroll, test/mirror, and compatibility exploration: complete. +- GUI build/typecheck: passed. +- Gateway WebUI build/typecheck: passed. +- GUI full frontend tests after all additions: 1,423/1,428 passed. The five failures exactly match the unmodified `849daf26` baseline (four mention source-extraction failures and one provider-preset byte-sync failure); baseline had 1,410/1,415 passed with the same five failures. +- Gateway WebUI full tests after all additions: 504/504 passed. +- Focused activity/identity/scroll tests: passed, including 100 appended tools, stable live-to-settled keys, interleaved reasoning/tool/result order, and one-frame pin coalescing. +- Mirrored live-caret regression tests assert that neither GUI nor WebUI round content requests a Markdown caret; both focused tests and both builds pass. +- GUI/WebUI status-width regression tests assert the non-expanding container chain and full-width truncation target; focused tests, touched-file lint, both builds, Mirror Check, and diff hygiene pass. +- Coverage audit follow-up: mirrored special-tool identity tests now cover `TodoWrite`, `AskUserQuestion`, `Image`, `Agent`, and hosted-search singleton-to-group stability. Gateway row tests now explicitly apply result B before result A, repeat result B, and assert the original A/B order, result ownership, error status, and outer assistant key remain stable. Focused rerun passed GUI 9/9 and WebUI 24/24. +- GUI lint: 419 errors / 358 warnings / 9 infos versus baseline 428 / 358 / 9. A final targeted Biome check of all 14 touched GUI source files exited successfully with three existing warnings and no errors. +- Gateway WebUI lint: 289 errors / 310 warnings / 10 infos versus baseline 296 / 310 / 10. A final targeted Biome check of all 11 touched WebUI source files exited successfully with 22 existing warnings and no errors. +- Mirror Check: 122/122 passed. +- `git diff --check`: passed; line-ending notices are working-tree conversion warnings, not whitespace errors. +- Independent read-only review: no P0/P1 finding. The final review identified delayed-twin alias loss and settling-status lifecycle as P2 risks. Alias retention is covered by the new-turn hydration test; the settling status intentionally remains visible until the persisted twin atomically takes over, matching the accepted GUI/WebUI behavior. +- Same-worktree Gateway WebUI runtime acceptance: frame-level bottom-follow and detached-reader probes passed after the status-height fix. +- Same-worktree Tauri and Gateway WebUI manual acceptance: passed by the user on 2026-08-01, including the follow-up removal of the stray Markdown caret, preservation of the visible Vibing status, and truncation of overlong status text without transcript-width growth. + +## Runtime provenance and preliminary browser checks + +- An older Tauri instance from `target\codex-transcript-order-scroll-jitter-worktree` owned port 1420. Only its verified running process tree was stopped; the old worktree was not modified or cleaned. +- Tauri was launched from this task worktree with `pnpm --dir crates/agent-gui tauri dev`; `LIBCLANG_PATH` was resolved from the local Python clang runtime without changing repository configuration. +- Task Vite's command line was rooted at this task worktree and listened on `127.0.0.1:1420`. +- The desktop binary resolved to this worktree's `target\debug\liveagent.exe`, was rebuilt for the acceptance run, and Windows reported the `LiveAgent` window responsive. Source HEAD remained `849daf269762846557cc8243c5fe6e7fb155ed72` plus the uncommitted task diff. +- Gateway was started from this task source with `go run ./cmd/gateway`, listening at `127.0.0.1:18080` with an isolated temporary agent database; no credential value is recorded in this worklog. +- WebUI Vite was rooted in this task worktree. Because the package script forwarded an extra literal `--`, Vite ignored the requested 15173 value; with an older unrelated server already on 5173, this verified task server selected `http://127.0.0.1:5174`. Its root returned HTTP 200 and the expected title. +- A headed Playwright browser authenticated against the task Gateway, and the desktop agent connected to that independent Gateway. The real acceptance conversation executed tools from the task worktree and reported the required branch and baseline HEAD. +- Gateway WebUI acceptance preview: `docs/images/live-transcript-stability-webui.png` captures the completed sequential run in the verified task client. +- At 390x844, document and body widths remain 390px with no horizontal overflow. Sidebar overlay interception followed the normal close-sidebar path. Dark mode applied (`html.dark`, `color-scheme: dark`) and emulated `prefers-reduced-motion: reduce` matched true. Screenshot: `output/playwright/webui-narrow-dark-reduced.png` (acceptance artifact only, not a task source file). +- The first 12-step live run kept two outer transcript rows (one user and one assistant activity) while more than 24 internal activity buttons accumulated, proving that live growth stayed inside one assistant row. +- Diagnostic sampling before the final status fix recorded 57 explicit scroll writes, all owned by `useScrollFollow`, and repeated `+15.2px/-15.2px` pairs. This ruled out a second JavaScript writer and identified conditional/wrapping status height as the remaining oscillator. +- After the stable single-line status fix, a new real six-tool run sampled 8,820 frames. All 20 explicit writes came from `useScrollFollow`; the repeated negative 15.2px deltas disappeared. After the initial pending-to-assistant measurement (`+236px`, then one `-4px` estimate correction), every live tool increment was non-negative and the final bottom gap was 0px. +- A trusted wheel gesture detached the same page by about 700px during a later live run. Across 7,396 animation-frame samples, the selected visible row had 0px maximum/final top drift, 0 removals, 0 reinsertions, and the bottom gap grew from about 700px to 1,361.6px. Live output therefore did not steal the detached reader's position. +- Browser frame probes are prepared under `output/playwright/`: bottom-follow and detached variants sample every animation frame and track the exact row node with a `MutationObserver`; the stop probe reports sample count, node removals/reinsertions, direction reversals, maximum anchor drift, and final bottom gap. These are acceptance artifacts only and will not be staged. +- Tauri, GUI Vite, Gateway, and WebUI Vite all remained rooted in the task worktree throughout the probes. No commit, push, or PR update had occurred before manual acceptance. + +## Manual acceptance matrix + +All prompts below are read-only unless the row explicitly asks the user to press Stop or toggle the Remote connection. Run the observable rows once in the same-worktree Tauri window and once in the task WebUI. The automated-only rows are covered by the commands/results above and are not represented as manual UI checks. + +| Scenario | Client | Trigger and steps | Expected result | +| --- | --- | --- | --- | +| Thinking → tool → thinking | GUI + WebUI | Send the sequential twelve-step prompt below. Keep the transcript at the bottom for steps 1–4. | Existing thinking/tool rows never exchange positions; each tool updates in place; no up/down direction reversal. | +| Twelve tools, long activity list | GUI + WebUI | Let the complete sequential prompt finish. Expand and scroll the activity region while later steps arrive. | First tool keeps the same DOM identity; new items append; no prior item remounts; scroll remains usable through all twelve steps. | +| Detached reader | GUI + WebUI | During steps 5–8, scroll the transcript upward until the “back to bottom” affordance appears and keep the pointer still. | The visible anchor top stays within about 1px; new activity does not steal the viewport. | +| Return to bottom | GUI + WebUI | Activate the return-to-bottom affordance once, then leave the viewport untouched. | It returns once and subsequent growth stays bottom-pinned with at most one final correction per frame. | +| Thinking details overlay | GUI + WebUI | Activate a compact “Thinking process” row by mouse, keyboard Enter/Space, and touch emulation; then close using Escape, outside click, and focus departure. | A fixed dialog appears without changing transcript/composer height; trigger focus returns on Escape; reduced motion has no height animation. | +| Parallel and out-of-order tools | GUI + WebUI | Send the parallel-subagent prompt below. | Items remain in first-seen order even when the shorter subagent completes first; results update their original items. | +| Tool failure and recovery | GUI + WebUI | Send the failure prompt below. | The failed tool changes to failed in place, later successful tool appends after it, and the activity container remains stable. | +| Stop/cancel | GUI + WebUI | Send the stop prompt; after the long-running tool begins, press Stop once. | Running item becomes cancelled/aborted in place; no duplicate status row; turn settles once without a final jump. | +| Retry | GUI + WebUI | Use the existing retry action on the failed turn exactly once. | A new attempt is represented without reordering the settled prior turn; repeated click is not duplicated. | +| AskUserQuestion | GUI + WebUI | Send the question prompt; wait five seconds, select “继续”, submit once. | Pending card and surrounding activities do not move; answering resumes the same turn; duplicate submission is blocked. | +| TodoWrite compatibility | GUI + WebUI | The twelve-step prompt creates and updates the complete TodoWrite list before every step. | Existing TodoWrite bubble/progress behavior remains available; hidden TodoWrite never splits adjacent ordinary tools or changes their identity. | +| Image | GUI + WebUI | Attach a small image and ask the agent to inspect its dimensions/read visible text, without editing files. | Image tool/activity stays at its original position as result arrives; preview/details still open. | +| Hosted search | GUI + WebUI | Ask: “使用 hosted search 查找 LiveAgent 仓库主页,只返回标题和 URL。” | Search row updates in place and does not regroup neighboring shell/file tools. | +| Subagent | GUI + WebUI | Use the parallel-subagent prompt. | Both subagent activities keep stable identity, progress/result details remain accessible. | +| Narrow/light/dark/reduced motion | WebUI + GUI | Repeat overlay and live-list checks at ~390px, light and dark theme, with reduced motion enabled. | No horizontal overflow; overlay is clamped; no layout-height animation. | +| Conversation switch/history restore | GUI + WebUI | While idle, switch to another conversation and back; reload WebUI once. | Restored settled order equals the live order and no duplicate/remounted activity appears. | +| Gateway reconnect | WebUI | During a long step, disable Remote Access in Tauri for 3–5 seconds, then re-enable with unchanged settings. | Browser reports disconnect/reconnect, rehydrates the same order, and does not duplicate or move existing activities. | +| History prepend/floor navigation | GUI + WebUI | Open a long conversation, scroll upward to load older history, then use floor navigation once. | The visible keyed anchor is preserved; the one explicit navigation owns the scroll; no corrective bounce. | +| Invalid/duplicate/late events | Automated only | GUI/WebUI projection and store tests, including replay/reconnect and delayed-result fixtures. | Existing IDs and relative order remain unchanged; invalid/duplicate events are idempotent. | +| 50/100-item stress | Automated only | `transcript-row-model.test.mjs` and Web projection tests; 100 appended ordinary tools are built incrementally. | One outer activity key and the first tool key survive; no identity churn or maximum-depth/ResizeObserver error. | + +### Sequential twelve-step prompt + +```text +这是实时活动稳定性验收。不要修改任何文件,不要并行、合并、跳过或批量完成步骤。 + +1. 首先调用 TodoWrite,一次性创建下面完整的 12 项任务,名称和顺序后续不得改变;只将第 1 项设为 in_progress,其余设为 pending。 +2. 每完成一项,必须立即重新调用 TodoWrite,提交完整 12 项列表:刚完成项设为 completed,下一项设为 in_progress,其余状态保持不变;一次只能完成一项。 +3. 每次 TodoWrite 更新后执行 Start-Sleep -Seconds 2,再执行下一项。 +4. 每项必须使用一次独立工具调用,严格串行: + 1) 获取当前工作目录 + 2) 获取当前 Git 分支 + 3) 获取当前 HEAD SHA + 4) 查看 git status --short + 5) 获取 Node.js 版本 + 6) 获取 pnpm 版本 + 7) 获取 rustc 版本 + 8) 获取 Cargo 版本 + 9) 获取 Go 版本 + 10) 检查 crates/agent-gui/package.json 是否存在 + 11) 检查 crates/agent-gateway/web/package.json 是否存在 + 12) 只读汇总前面结果 +5. 在相邻工具之间可以简短说明当前进度,但不要修改文件,不要调用 AskUserQuestion。 +``` + +### Parallel/out-of-order subagent prompt + +```text +只读验收,不修改文件。连续启动两个独立 Agent/subagent 活动,保持首次出现顺序:第一个等待 4 秒后读取当前分支;第二个等待 1 秒后读取当前 HEAD。允许并行并让第二个先返回。两者完成后再用一个普通 shell 工具汇总结果。不得重排或重新命名已有活动。 +``` + +### Failure/recovery prompt + +```text +只读验收。先执行一个必然失败且不修改系统的命令,输出固定错误并以非零状态退出;失败后不要重试该命令,继续用新的独立工具调用执行 git rev-parse HEAD,最后说明两个结果。不要并行。 +``` + +### Stop prompt + +```text +只读验收。先说明将开始等待,然后执行一个持续 30 秒的等待命令;等待结束后才允许读取当前分支。不要后台运行,不要并行。我会在等待期间按一次 Stop。 +``` + +### AskUserQuestion prompt + +```text +只读验收。先调用 AskUserQuestion 询问“是否继续稳定性验收?”,只提供“继续”和“取消”两个选项。在我回答前不要调用其他工具;选择继续后读取当前 HEAD 并结束。 +``` + +## Frame-level evidence protocol + +- Browser probe chooses the transcript `[data-scroll-viewport]`, captures the currently stable assistant row node and its `getBoundingClientRect().top`, and samples it with `scrollTop` on every animation frame. +- A `MutationObserver` records whether that exact node is removed/reinserted while later tool rows arrive. +- Bottom-follow acceptance: final bottom gap ≤1px, no repeated sign reversal among meaningful (>0.5px) live-tool deltas, and no more than one final pin per sampled frame. A one-time initial estimate correction is recorded separately from steady-state live growth. +- Detached acceptance: select a visible historical row as anchor after the user scrolls up; maximum absolute top drift target ≤1px while new activity and ResizeObserver measurements land. +- Record sample count, maximum anchor drift, direction reversals, node removal count, and final bottom gap for both desktop-observable and WebUI runs. WebUI is sampled directly through Playwright; desktop uses the same visual scenario plus source/process provenance because the Tauri WebView is not exposed as a Playwright page. + +## Resume + +Manual acceptance is complete. Continue only from the task worktree above: stage task source and this worklog explicitly (excluding `.playwright-cli/`, `output/`, and the content-identical `crates/agent-gui/src-tauri/Cargo.toml` status artifact), review the staged diff, commit, push, create the upstream PR with `Depends-On: none`, and monitor required CI to terminal state. diff --git a/scripts/mirror-manifest.json b/scripts/mirror-manifest.json index 30bd47234..e92e30a3d 100644 --- a/scripts/mirror-manifest.json +++ b/scripts/mirror-manifest.json @@ -31,6 +31,7 @@ "components/project-tools/file-tree/ContextMenu.tsx", "components/chat/promptHistory.ts", "components/chat/NotifyToast.tsx", + "components/chat/ThinkingActivity.tsx", "components/chat/fileTypeIcons.tsx", "components/chat/ComposerAttachmentCard.tsx", "components/chat/AskUserQuestionCard.tsx", @@ -40,6 +41,7 @@ "lib/chat/askUserQuestion.ts", "lib/chat/openChatFileLink.ts", "lib/chat/toolApprovalArgs.ts", + "lib/chat/thinkingOverlayModel.ts", "components/Markdown.tsx", "lib/markdownCodeBlockPolicy.ts", "lib/normalizeLatexDelimiters.ts", @@ -53,6 +55,7 @@ "components/workspace-editor/workspaceMarkdownAssets.ts", "components/workspace-editor/WorkspaceMarkdownPreview.tsx", "lib/chat-scroll/scrollFollowCore.ts", + "lib/chat-scroll/framePinController.ts", "lib/chat-scroll/useScrollFollow.ts", "lib/chat-floor-nav/floorModel.ts", "lib/chat-floor-nav/floorBookmarks.ts",