diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index a8dce2858..e67302c5f 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -4899,6 +4899,9 @@ export default function GatewayApp() { liveStartIndex={transcriptLiveStartIndex} activeTurnKey={displayedTranscript.activeTurnKey} contentWidth={settings.customSettings.chatTranscript.width} + processDetailsExpanded={ + settings.customSettings.chatTranscript.processDetailsExpanded + } isViewportFollowing={transcriptFollow.isFollowing} navRef={transcriptNavRef} onAnchorUserRowChange={setActiveFloorKey} diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index f3a110549..b1dd7bc6d 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -38,6 +38,7 @@ import { import type { GitClient } from "@/lib/git/types"; import { DEFAULT_CHAT_TRANSCRIPT_WIDTH } from "@/lib/settings"; import { cn } from "@/lib/shared/utils"; +import { estimateAssistantResponseRowHeight } from "@/lib/transcript-virtual/assistantResponseEstimate"; import { extractLiveRange } from "@/lib/transcript-virtual/liveRangeExtractor"; import { createLiveRowScrollAdjustPolicy } from "@/lib/transcript-virtual/liveScrollAdjustPolicy"; import { @@ -46,9 +47,7 @@ import { } from "@/lib/transcript-virtual/measurementsLru"; import { CHECKPOINT_ROW_ESTIMATE_PX, - estimateAssistantRowHeight, estimateUserRowHeight, - measureEstimateText, } from "@/lib/transcript-virtual/rowEstimates"; import { AssistantAvatar, @@ -89,6 +88,7 @@ type GatewayTranscriptProps = { // Key of the actively streaming turn (caret / live structural state). activeTurnKey?: string | null; contentWidth?: number; + processDetailsExpanded?: boolean; // Whether the scroll-follow engine is attached to the bottom; gates the // virtualizer's resize-compensation carve-out for live-row growth. isViewportFollowing?: () => boolean; @@ -1120,14 +1120,20 @@ const GatewayAssistantMessageActions = memo(function GatewayAssistantMessageActi ); }); -const rowEstimateCache = new WeakMap(); +type CachedRowEstimates = { + collapsed?: number; + expanded?: number; +}; + +const rowEstimateCache = new WeakMap(); // Content-shaped height estimates: only ever used for rows the virtualizer // has never measured (the measurement cache is keyed by row key and survives // folding), but a shaped guess keeps scroll corrections small while reading // unmeasured history. -function estimateRowHeight(row: TranscriptRow): number { - const cached = rowEstimateCache.get(row); +function estimateRowHeight(row: TranscriptRow, processDetailsExpanded: boolean): number { + const variant = processDetailsExpanded ? "expanded" : "collapsed"; + const cached = rowEstimateCache.get(row)?.[variant]; if (cached !== undefined) { return cached; } @@ -1135,45 +1141,35 @@ function estimateRowHeight(row: TranscriptRow): number { if (row.kind === "user") { estimate = estimateUserRowHeight(row.text.length, row.attachments.length); } else if (row.kind === "assistant") { - let proseChars = 0; - let codeLines = 0; - let codeFences = 0; - let toolCount = 0; - let thinkingCount = 0; - for (const round of row.rounds) { - for (const block of round.blocks) { - if (block.kind === "text") { - const measured = measureEstimateText(block.text); - proseChars += measured.proseChars; - codeLines += measured.codeLines; - codeFences += measured.codeFences; - } else if (block.kind === "thinking") { - thinkingCount += 1; - } else { - toolCount += 1; - } - } - } - estimate = estimateAssistantRowHeight({ - proseChars, - codeLines, - codeFences, - toolCount, - thinkingCount, - }); + estimate = estimateAssistantResponseRowHeight(row.rounds, processDetailsExpanded); } else if (row.kind === "checkpoint") { estimate = CHECKPOINT_ROW_ESTIMATE_PX; } else { estimate = 120; } - rowEstimateCache.set(row, estimate); + const nextCache = rowEstimateCache.get(row) ?? {}; + nextCache[variant] = estimate; + rowEstimateCache.set(row, nextCache); return estimate; } -function estimateVirtualItemHeight(item: GatewayTranscriptVirtualItem): number { +function estimateVirtualItemHeight( + item: GatewayTranscriptVirtualItem, + processDetailsExpanded: boolean, +): number { if (item.kind === "loadRemoteHistory") return 44; if (item.kind === "pendingBubble") return 56; - return estimateRowHeight(item.row); + return estimateRowHeight(item.row, processDetailsExpanded); +} + +function buildGatewayTranscriptLayoutKey( + viewportWidth: number, + contentWidth: number, + processDetailsExpanded: boolean, +) { + const baseKey = buildTranscriptLayoutKey(viewportWidth, contentWidth); + if (!baseKey) return ""; + return `${baseKey}:process-details-${processDetailsExpanded ? "expanded" : "collapsed"}`; } const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(props: { @@ -1182,6 +1178,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr liveStartIndex: number; activeTurnKey?: string | null; contentWidth: number; + processDetailsExpanded: boolean; scrollViewport: HTMLDivElement | null; isViewportFollowing?: () => boolean; navRef?: MutableRefObject; @@ -1216,6 +1213,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr liveStartIndex, activeTurnKey, contentWidth, + processDetailsExpanded, scrollViewport, isViewportFollowing, navRef, @@ -1345,7 +1343,11 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr (conversationId && scrollViewport ? transcriptMeasurementsLru.restore( conversationId, - buildTranscriptLayoutKey(scrollViewport.clientWidth, contentWidth), + buildGatewayTranscriptLayoutKey( + scrollViewport.clientWidth, + contentWidth, + processDetailsExpanded, + ), ) : null) ?? [], ); @@ -1355,7 +1357,9 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr getScrollElement: () => scrollViewport, estimateSize: (index) => { const item = virtualItems[index]; - return item ? estimateVirtualItemHeight(item) : TRANSCRIPT_ROW_ESTIMATED_HEIGHT; + return item + ? estimateVirtualItemHeight(item, processDetailsExpanded) + : TRANSCRIPT_ROW_ESTIMATED_HEIGHT; }, getItemKey: getTranscriptItemKey, gap: TRANSCRIPT_ROW_GAP, @@ -1376,6 +1380,16 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr rangeExtractor: (range) => extractLiveRange(range, forceMountStartRef.current), }); + const previousProcessDetailsExpandedRef = useRef(processDetailsExpanded); + useLayoutEffect(() => { + if (previousProcessDetailsExpandedRef.current === processDetailsExpanded) return; + previousProcessDetailsExpandedRef.current = processDetailsExpanded; + // The preference can change every unmounted history row's default height + // at once. Clear virtualizer measurements without changing React row keys, + // preserving any per-response manual disclosure choices on mounted rows. + transcriptVirtualizer.measure(); + }, [processDetailsExpanded, transcriptVirtualizer]); + // 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 @@ -1577,7 +1591,11 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr if (!conversationId || !scrollViewport) return; transcriptMeasurementsLru.save( conversationId, - buildTranscriptLayoutKey(scrollViewport.clientWidth, contentWidth), + buildGatewayTranscriptLayoutKey( + scrollViewport.clientWidth, + contentWidth, + processDetailsExpanded, + ), transcriptVirtualizer.takeSnapshot(), ); }; @@ -1717,6 +1735,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr >
@@ -1795,6 +1815,7 @@ export function GatewayTranscript({ liveStartIndex = -1, activeTurnKey = null, contentWidth = DEFAULT_CHAT_TRANSCRIPT_WIDTH, + processDetailsExpanded = false, isViewportFollowing, navRef, onAnchorUserRowChange, @@ -1882,6 +1903,7 @@ export function GatewayTranscript({ liveStartIndex={liveStartIndex} activeTurnKey={activeTurnKey} contentWidth={contentWidth} + processDetailsExpanded={processDetailsExpanded} scrollViewport={transcriptScrollViewport} isViewportFollowing={isViewportFollowing} navRef={navRef} diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 665ded047..8ae7384f6 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -142,6 +142,7 @@ export const translations: Record> = { "chat.stopGeneration": "停止生成", "chat.thinking": "思考中", "chat.thinkingProcess": "思考过程", + "chat.processDetails": "过程详情", "chat.retryDetailsToggle": "重试详情 ({count})", "chat.retryAttemptLabel": "第 {attempt}/{maxAttempts} 次重试", "chat.runtime.thinkingOn": "Thinking 已开启", @@ -1171,6 +1172,9 @@ export const translations: Record> = { /* ── Settings System ── */ "settings.appearance": "外观主题", "settings.appearanceDesc": "选择应用的颜色主题,偏好会自动保存。", + "settings.processDetailsExpanded": "默认展开过程详情", + "settings.processDetailsExpandedDesc": + "开启后,思考和工具调用默认展开;关闭后,生成正式回答时自动收起,仍可手动查看。", "settings.systemProxy": "应用代理", "settings.systemProxyEnable": "启用应用代理", "settings.systemProxyDesc": @@ -2323,6 +2327,7 @@ export const translations: Record> = { "chat.stopGeneration": "Stop Generation", "chat.thinking": "Thinking", "chat.thinkingProcess": "Thinking Process", + "chat.processDetails": "Process details", "chat.retryDetailsToggle": "Retry details ({count})", "chat.retryAttemptLabel": "Retry {attempt}/{maxAttempts}", "chat.runtime.thinkingOn": "Thinking enabled", @@ -3391,6 +3396,9 @@ export const translations: Record> = { "settings.appearance": "Appearance", "settings.appearanceDesc": "Choose the color theme for the application. Your preference will be saved automatically.", + "settings.processDetailsExpanded": "Expand process details by default", + "settings.processDetailsExpandedDesc": + "Keep reasoning and tool activity expanded by default. When disabled, they collapse as the final answer appears and can still be opened manually.", "settings.systemProxy": "App Proxy", "settings.systemProxyEnable": "Enable app proxy", "settings.systemProxyDesc": diff --git a/crates/agent-gateway/web/src/lib/chat/processDetailsDisclosureState.ts b/crates/agent-gateway/web/src/lib/chat/processDetailsDisclosureState.ts new file mode 100644 index 000000000..eced760f6 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/processDetailsDisclosureState.ts @@ -0,0 +1,21 @@ +const MAX_MANUAL_PROCESS_DETAILS_STATES = 1_000; + +const manualOpenByDisclosureKey = new Map(); + +export function readManualProcessDetailsOpen(disclosureKey: string): boolean | undefined { + const value = manualOpenByDisclosureKey.get(disclosureKey); + if (value === undefined) return undefined; + manualOpenByDisclosureKey.delete(disclosureKey); + manualOpenByDisclosureKey.set(disclosureKey, value); + return value; +} + +export function writeManualProcessDetailsOpen(disclosureKey: string, open: boolean): void { + manualOpenByDisclosureKey.delete(disclosureKey); + manualOpenByDisclosureKey.set(disclosureKey, open); + while (manualOpenByDisclosureKey.size > MAX_MANUAL_PROCESS_DETAILS_STATES) { + const oldestKey = manualOpenByDisclosureKey.keys().next().value; + if (oldestKey === undefined) break; + manualOpenByDisclosureKey.delete(oldestKey); + } +} diff --git a/crates/agent-gateway/web/src/lib/chat/processDetailsModel.ts b/crates/agent-gateway/web/src/lib/chat/processDetailsModel.ts new file mode 100644 index 000000000..ca4acc347 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/processDetailsModel.ts @@ -0,0 +1,127 @@ +export type AssistantResponseBlockLike = { + kind: string; + text?: string; + item?: unknown; +}; + +export type AssistantResponseRoundLike = { + blocks: readonly AssistantResponseBlockLike[]; + meta?: { stopReason?: unknown }; +}; + +export type PartitionedAssistantRound = { + round: TRound; + blocks: TBlock[]; +}; + +export type AssistantResponsePartition = { + processRounds: PartitionedAssistantRound[]; + answerRounds: PartitionedAssistantRound[]; + hasProcessDetails: boolean; + hasSubstantiveAnswer: boolean; +}; + +export type PartitionAssistantResponseOptions = { + preserveStreamingText?: boolean; +}; + +export function isProcessDetailsBlock(block: AssistantResponseBlockLike): boolean { + return block.kind === "thinking" || block.kind === "tool" || block.kind === "hostedSearch"; +} + +export function partitionAssistantResponse< + TBlock extends AssistantResponseBlockLike, + TRound extends { blocks: readonly TBlock[] }, +>( + rounds: readonly TRound[], + options: PartitionAssistantResponseOptions = {}, +): AssistantResponsePartition { + let blockPosition = 0; + let lastProcessPosition = -1; + + for (const round of rounds) { + for (const block of round.blocks) { + if (isProcessDetailsBlock(block)) { + lastProcessPosition = blockPosition; + } + blockPosition += 1; + } + } + + const processRounds: PartitionedAssistantRound[] = []; + const answerRounds: PartitionedAssistantRound[] = []; + let hasSubstantiveAnswer = false; + blockPosition = 0; + + for (const round of rounds) { + const processBlocks: TBlock[] = []; + const answerBlocks: TBlock[] = []; + + for (const block of round.blocks) { + if ( + blockPosition <= lastProcessPosition && + !(options.preserveStreamingText && block.kind === "text") + ) { + processBlocks.push(block); + } else { + answerBlocks.push(block); + if (block.kind === "text" && /\S/.test(block.text ?? "")) { + hasSubstantiveAnswer = true; + } + } + blockPosition += 1; + } + + if (processBlocks.length > 0) { + processRounds.push({ round, blocks: processBlocks }); + } + if (answerBlocks.length > 0) { + answerRounds.push({ round, blocks: answerBlocks }); + } + } + + return { + processRounds, + answerRounds, + hasProcessDetails: lastProcessPosition >= 0, + hasSubstantiveAnswer, + }; +} + +export function getProcessDetailsDefaultOpen(input: { + hasSubstantiveAnswer: boolean; + expandByDefault: boolean; +}): boolean { + return !input.hasSubstantiveAnswer || input.expandByDefault; +} + +export function shouldForceProcessDetailsOpen( + rounds: readonly AssistantResponseRoundLike[], +): boolean { + const { hasSubstantiveAnswer } = partitionAssistantResponse(rounds); + + for (const round of rounds) { + const stopReason = round.meta?.stopReason; + if (stopReason === "aborted" || stopReason === "error") return true; + + for (const block of round.blocks) { + if (block.kind !== "tool" || !block.item || typeof block.item !== "object") continue; + const toolResult = (block.item as { toolResult?: unknown }).toolResult; + if (!toolResult || typeof toolResult !== "object") continue; + const result = toolResult as { isError?: unknown; details?: unknown }; + // A tool-level error can be recovered by a later tool call. Once a + // substantive answer exists, that recovered error must not override the + // user's disclosure preference. Without an answer, keep the failure + // visible even if the user collapsed the active process manually. + if (result.isError === true && !hasSubstantiveAnswer) return true; + if ( + result.details && + typeof result.details === "object" && + (result.details as { timedOut?: unknown }).timedOut === true + ) { + return true; + } + } + } + return false; +} diff --git a/crates/agent-gateway/web/src/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index 7f3fbeafd..6448106dc 100644 --- a/crates/agent-gateway/web/src/lib/settings/index.ts +++ b/crates/agent-gateway/web/src/lib/settings/index.ts @@ -148,6 +148,7 @@ export { DEFAULT_CHAT_TRANSCRIPT_WIDTH, MAX_CHAT_TRANSCRIPT_WIDTH, MIN_CHAT_TRAN export type ChatTranscriptSettings = { width: number; + processDetailsExpanded: boolean; }; export type CustomSettings = { @@ -2190,6 +2191,7 @@ export function normalizeChatTranscriptSettings(input: unknown): ChatTranscriptS MAX_CHAT_TRANSCRIPT_WIDTH, DEFAULT_CHAT_TRANSCRIPT_WIDTH, ), + processDetailsExpanded: obj.processDetailsExpanded === true, }; } @@ -2515,7 +2517,9 @@ export function getRightDockProjectState( export function updateChatTranscriptWidth(prev: AppSettings, width: number): AppSettings { const nextWidth = normalizeChatTranscriptSettings({ width }).width; if (prev.customSettings.chatTranscript.width === nextWidth) return prev; - return updateCustomSettings(prev, { chatTranscript: { width: nextWidth } }); + return updateCustomSettings(prev, { + chatTranscript: { ...prev.customSettings.chatTranscript, width: nextWidth }, + }); } export function updateRightDockWidth(prev: AppSettings, width: number): AppSettings { diff --git a/crates/agent-gateway/web/src/lib/settings/sync.ts b/crates/agent-gateway/web/src/lib/settings/sync.ts index 6bb641dfd..8dd6a2a6e 100644 --- a/crates/agent-gateway/web/src/lib/settings/sync.ts +++ b/crates/agent-gateway/web/src/lib/settings/sync.ts @@ -468,12 +468,15 @@ function syncableCustomSettings( projectsCollapsed: false, recentCollapsed: false, }, - // Typography, scale, and transcript width are local UI preferences; fixed - // defaults prevent visual preferences from being broadcast through the gateway. + // Typography, scale, and transcript presentation are local UI preferences; + // fixed defaults prevent visual preferences from being broadcast through the gateway. interfaceFontFamily: "", chatFontFamily: "", codeFontFamily: "", - chatTranscript: { width: DEFAULT_CHAT_TRANSCRIPT_WIDTH }, + chatTranscript: { + width: DEFAULT_CHAT_TRANSCRIPT_WIDTH, + processDetailsExpanded: false, + }, fontScale: { sidebar: 1, chat: 1, rightDock: 1 }, }; } @@ -1217,7 +1220,8 @@ export function applyGatewaySettingsSyncPayload( ) : current.customSettings.rightDock, chatSidebar: current.customSettings.chatSidebar, - // Typography, scale, and transcript width are local UI preferences, never gateway-synced. + // Typography, scale, and transcript presentation are local UI preferences, + // never gateway-synced. interfaceFontFamily: current.customSettings.interfaceFontFamily, chatFontFamily: current.customSettings.chatFontFamily, codeFontFamily: current.customSettings.codeFontFamily, diff --git a/crates/agent-gateway/web/src/lib/transcript-virtual/assistantResponseEstimate.ts b/crates/agent-gateway/web/src/lib/transcript-virtual/assistantResponseEstimate.ts new file mode 100644 index 000000000..ca7dbaeba --- /dev/null +++ b/crates/agent-gateway/web/src/lib/transcript-virtual/assistantResponseEstimate.ts @@ -0,0 +1,127 @@ +import { + getProcessDetailsDefaultOpen, + partitionAssistantResponse, + shouldForceProcessDetailsOpen, +} from "../chat/processDetailsModel"; +import { + type AssistantRowEstimateStats, + estimateAssistantRowHeight, + measureEstimateText, +} from "./rowEstimates"; + +type EstimableToolResult = { + isError?: boolean; + details?: unknown; +}; + +type EstimableToolItem = { + toolCall?: { name?: string }; + toolResult?: EstimableToolResult; +}; + +export type EstimableAssistantBlock = { + kind: string; + text?: string; + item?: unknown; +}; + +export type EstimableAssistantRound = { + blocks: readonly TBlock[]; + meta?: { stopReason?: unknown }; +}; + +const FILE_CHANGE_TOOL_NAMES = new Set(["Write", "Edit", "Delete"]); + +function addMarkdownEstimate(stats: AssistantRowEstimateStats, text: string) { + const measured = measureEstimateText(text); + stats.proseChars += measured.proseChars; + stats.codeLines += measured.codeLines; + stats.codeFences += measured.codeFences; +} + +function addVisibleBlockEstimate( + stats: AssistantRowEstimateStats, + block: EstimableAssistantBlock, + collapseThinking = false, +) { + if (block.kind === "thinking" && collapseThinking) { + stats.thinkingCount += 1; + return; + } + if (block.kind === "text" || block.kind === "thinking") { + addMarkdownEstimate(stats, block.text ?? ""); + return; + } + if (block.kind === "tool" || block.kind === "hostedSearch") { + stats.toolCount += 1; + } +} + +function addArtifactReserve( + stats: AssistantRowEstimateStats, + rounds: readonly EstimableAssistantRound[], +) { + let hasChangedFilesCard = false; + let displayImageCount = 0; + + for (const round of rounds) { + for (const block of round.blocks) { + if (block.kind !== "tool") continue; + const item = + block.item && typeof block.item === "object" + ? (block.item as EstimableToolItem) + : undefined; + const { toolCall, toolResult } = item ?? {}; + if (!toolResult || toolResult.isError) continue; + if (toolCall?.name && FILE_CHANGE_TOOL_NAMES.has(toolCall.name)) { + hasChangedFilesCard = true; + } + const details = toolResult.details; + if ( + details && + typeof details === "object" && + (details as { kind?: unknown }).kind === "display_image" + ) { + displayImageCount += 1; + } + } + } + + // Both artifacts stay outside the process disclosure. Express their rough + // height as extra collapsed-row equivalents so closing process details does + // not make the virtualizer forget content that remains visible. + if (hasChangedFilesCard) stats.toolCount += 4; + stats.toolCount += displayImageCount * 7; +} + +export function estimateAssistantResponseRowHeight< + TBlock extends EstimableAssistantBlock, + TRound extends EstimableAssistantRound, +>(rounds: readonly TRound[], expandProcessDetailsByDefault: boolean): number { + const partition = partitionAssistantResponse(rounds); + const stats: AssistantRowEstimateStats = { + proseChars: 0, + codeLines: 0, + codeFences: 0, + toolCount: partition.hasProcessDetails ? 1 : 0, + thinkingCount: 0, + }; + const processDetailsOpen = + shouldForceProcessDetailsOpen(rounds) || + getProcessDetailsDefaultOpen({ + hasSubstantiveAnswer: partition.hasSubstantiveAnswer, + expandByDefault: expandProcessDetailsByDefault, + }); + + if (processDetailsOpen) { + for (const { blocks } of partition.processRounds) { + for (const block of blocks) addVisibleBlockEstimate(stats, block, true); + } + } + for (const { blocks } of partition.answerRounds) { + for (const block of blocks) addVisibleBlockEstimate(stats, block); + } + addArtifactReserve(stats, rounds); + + return estimateAssistantRowHeight(stats); +} diff --git a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx index 5758c463f..52100f0bf 100644 --- a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx +++ b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx @@ -2,9 +2,19 @@ import { memo, useMemo } from "react"; import { ChangedFilesCard } from "../../components/chat/ChangedFilesCard"; import { collectChangedFiles } from "../../lib/chat/changedFiles"; import type { ChatFileLink } from "../../lib/chat/chatFileLinks"; +import { + partitionAssistantResponse, + shouldForceProcessDetailsOpen, +} from "../../lib/chat/processDetailsModel"; import type { UiRound } from "../../lib/chat/uiMessages"; import { AssistantAvatar } from "./assistant-bubble/AssistantAvatar"; +import { isBuiltinShareToolName } from "./assistant-bubble/assistantBubbleUtils"; +import { ProcessDetailsDisclosure } from "./assistant-bubble/ProcessDetailsDisclosure"; import { RoundContent } from "./assistant-bubble/RoundContent"; +import { + getNativeDisplayImagePayload, + NativeDisplayImageBlock, +} from "./assistant-bubble/ToolImages"; export { AssistantAvatar } from "./assistant-bubble/AssistantAvatar"; export { RetryDetailsBlock } from "./assistant-bubble/RoundContent"; @@ -12,12 +22,15 @@ export { AssistantStatus, CompactingText, VibingText } from "./assistant-bubble/ const EMPTY_RUNNING_TOOL_CALL_IDS: string[] = []; +type AssistantBubbleRound = UiRound & { + key?: string; + runningToolCallIds?: string[]; + thinkingOpen?: boolean; +}; + export const AssistantBubble = memo(function AssistantBubble(props: { - rounds: (UiRound & { - key?: string; - runningToolCallIds?: string[]; - thinkingOpen?: boolean; - })[]; + disclosureKey: string; + rounds: AssistantBubbleRound[]; showUsage?: boolean; usageContextWindow?: number; isLive?: boolean; @@ -35,10 +48,12 @@ export const AssistantBubble = memo(function AssistantBubble(props: { toolStatusVariant?: "default" | "compaction"; readOnly?: boolean; redactToolContent?: boolean; + processDetailsExpanded: boolean; workdir?: string; onOpenFileLink?: (link: ChatFileLink) => void; }) { const { + disclosureKey, rounds, showUsage, usageContextWindow, @@ -49,6 +64,7 @@ export const AssistantBubble = memo(function AssistantBubble(props: { toolStatusVariant, readOnly = false, redactToolContent = false, + processDetailsExpanded, workdir, onOpenFileLink, } = props; @@ -68,39 +84,131 @@ export const AssistantBubble = memo(function AssistantBubble(props: { () => rounds.some((round) => round.meta?.stopReason === "aborted"), [rounds], ); + const forceProcessDetailsOpen = useMemo(() => shouldForceProcessDetailsOpen(rounds), [rounds]); // 回复末尾的已编辑文件卡:聚合整条回复所有 round 的 Write/Edit/Delete, // 只在回复结束(流停止)后出现;脱敏视图(分享页隐藏工具内容)不渲染。 const changedFiles = useMemo( () => (isStreaming || redactToolContent ? null : collectChangedFiles(rounds)), [isStreaming, redactToolContent, rounds], ); + const presentation = useMemo(() => { + const partition = partitionAssistantResponse( + rounds, + { preserveStreamingText: Boolean(isStreaming) }, + ); + const answerRoundSet = new Set(partition.answerRounds.map((entry) => entry.round)); + return { + ...partition, + processRounds: partition.processRounds.map((entry) => ({ + ...entry, + displayRound: { ...entry.round, blocks: entry.blocks }, + showUsage: !answerRoundSet.has(entry.round), + })), + answerRounds: partition.answerRounds.map((entry) => ({ + ...entry, + displayRound: { ...entry.round, blocks: entry.blocks }, + })), + }; + }, [isStreaming, rounds]); + const lastRound = rounds.at(-1); + const displayImages = useMemo( + () => + presentation.processRounds.flatMap(({ round, blocks }) => + blocks.flatMap((block, blockIndex) => { + if (block.kind !== "tool") return []; + if (redactToolContent && isBuiltinShareToolName(block.item.toolCall.name)) return []; + const payload = getNativeDisplayImagePayload(block.item); + if (!payload) return []; + const roundKey = round.key || `round-${round.round}`; + const toolKey = block.item.toolCall.id?.trim() || `${roundKey}-image-${blockIndex}`; + return [{ key: toolKey, payload }]; + }), + ), + [presentation.processRounds, redactToolContent], + ); return (
- {rounds.map((round, idx) => ( - + {() => + presentation.processRounds.map( + ({ round, displayRound, showUsage: roundShowUsage }) => { + const active = Boolean(isLive && round === lastRound); + return ( + + ); + }, + ) + } + + ) : null} + + {displayImages.map(({ key, payload }) => ( + ))} + + {presentation.answerRounds.map(({ round, displayRound }) => { + const active = Boolean(isLive && round === lastRound); + return ( + + ); + })} {changedFiles ? : null}
diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/HostedSearchGroupView.tsx b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/HostedSearchGroupView.tsx index d65017d4a..69b540210 100644 --- a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/HostedSearchGroupView.tsx +++ b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/HostedSearchGroupView.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { ChevronRight, Globe } from "../../../components/icons"; import { useLocale } from "../../../i18n"; import type { HostedSearchBlock } from "../../../lib/chat/hostedSearch"; @@ -82,12 +82,20 @@ function getHostedSearchCountLabel(count: number, t: (key: string) => string) { export function HostedSearchGroupView({ items, readOnly = false, + defaultOpen = false, }: { items: HostedSearchBlock[]; readOnly?: boolean; + defaultOpen?: boolean; }) { const { t } = useLocale(); - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(defaultOpen); + const userInteractedRef = useRef(false); + useEffect(() => { + if (!userInteractedRef.current) { + setOpen(defaultOpen); + } + }, [defaultOpen]); const queries = useMemo(() => getUniqueHostedSearchQueries(items), [items]); const sources = useMemo(() => getUniqueHostedSearchSources(items), [items]); const visibleSources = sources.slice(0, 10); @@ -106,7 +114,10 @@ export function HostedSearchGroupView({ "group/search flex w-full select-none items-center justify-between gap-3 py-1.5 text-left", !readOnly && "cursor-pointer", )} - onClick={() => setOpen((prev) => !prev)} + onClick={() => { + userInteractedRef.current = true; + setOpen((prev) => !prev); + }} >
diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ProcessDetailsDisclosure.tsx b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ProcessDetailsDisclosure.tsx new file mode 100644 index 000000000..d602803b2 --- /dev/null +++ b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ProcessDetailsDisclosure.tsx @@ -0,0 +1,67 @@ +import { memo, type ReactNode, useId, useState } from "react"; + +import { ChevronRight, Lightbulb } from "../../../components/icons"; +import { useLocale } from "../../../i18n"; +import { + readManualProcessDetailsOpen, + writeManualProcessDetailsOpen, +} from "../../../lib/chat/processDetailsDisclosureState"; +import { getProcessDetailsDefaultOpen } from "../../../lib/chat/processDetailsModel"; +import { LazyCollapse } from "./LazyCollapse"; + +export const ProcessDetailsDisclosure = memo(function ProcessDetailsDisclosure(props: { + disclosureKey: string; + hasSubstantiveAnswer: boolean; + isStreaming?: boolean; + expandByDefault: boolean; + forceOpen?: boolean; + children: () => ReactNode; +}) { + const { + disclosureKey, + hasSubstantiveAnswer, + isStreaming = false, + expandByDefault, + forceOpen = false, + children, + } = props; + const { t } = useLocale(); + const regionId = useId(); + const toggleId = `${regionId}-toggle`; + const automaticOpen = + forceOpen || + getProcessDetailsDefaultOpen({ + hasSubstantiveAnswer: hasSubstantiveAnswer && !isStreaming, + expandByDefault, + }); + const [manualOpen, setManualOpen] = useState(() => readManualProcessDetailsOpen(disclosureKey)); + const open = manualOpen ?? automaticOpen; + + return ( +
+ +
+ + {() =>
{children()}
} +
+
+
+ ); +}); 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..1400b89e5 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 @@ -17,6 +17,25 @@ import { UsagePanel } from "./UsagePanel"; const EMPTY_RUNNING_TOOL_CALL_IDS: string[] = []; +const ThinkingMarkdown = memo(function ThinkingMarkdown(props: { + text: string; + renderMode: "streaming" | "static"; + workdir?: string; + onOpenFileLink?: (link: ChatFileLink) => void; +}) { + const { text, renderMode, workdir, onOpenFileLink } = props; + return ( + + ); +}); + const ThinkingBlock = memo(function ThinkingBlock({ text, open, @@ -70,11 +89,9 @@ const ThinkingBlock = memo(function ThinkingBlock({ {() => (
- @@ -153,6 +170,7 @@ export const RoundContent = memo(function RoundContent(props: { redactToolContent?: boolean; latestTodoItem?: ToolTraceItem | null; isAborted?: boolean; + withinProcessDetails?: boolean; workdir?: string; onOpenFileLink?: (link: ChatFileLink) => void; }) { @@ -172,6 +190,7 @@ export const RoundContent = memo(function RoundContent(props: { redactToolContent = false, latestTodoItem, isAborted = false, + withinProcessDetails = false, workdir, onOpenFileLink, } = props; @@ -260,12 +279,13 @@ export const RoundContent = memo(function RoundContent(props: { {visibleGroupedBlocks.map((block) => { if (block.kind === "thinking") { + const isRunning = autoOpenThinking && block.key === latestThinkingKey; return ( ); } @@ -334,6 +356,7 @@ export const RoundContent = memo(function RoundContent(props: { key={block.key} items={block.kind === "hostedSearch" ? [block.item] : block.items} readOnly={readOnly} + defaultOpen={false} /> ); } 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..64be05eb8 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 @@ -1,4 +1,4 @@ -import { memo, useMemo, useState } from "react"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; import { ChevronRight, Terminal } from "../../../components/icons"; import { useLocale } from "../../../i18n"; import type { ToolTraceItem } from "../../../lib/chat/uiMessages"; @@ -61,6 +61,7 @@ function ToolTraceGroupInner(props: { readOnly?: boolean; redactToolContent?: boolean; isAborted?: boolean; + defaultOpen?: boolean; }) { const { items, @@ -68,6 +69,7 @@ function ToolTraceGroupInner(props: { readOnly = false, redactToolContent = false, isAborted = false, + defaultOpen = false, } = props; const { t } = useLocale(); const counts = useMemo( @@ -82,7 +84,14 @@ function ToolTraceGroupInner(props: { [allBash, dominantToolName], ); const ToolIcon = allBash ? Terminal : meta.Icon; - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(defaultOpen); + const userInteractedRef = useRef(false); + + useEffect(() => { + if (!userInteractedRef.current) { + setOpen(defaultOpen); + } + }, [defaultOpen]); const statusLabel = counts.failed > 0 @@ -106,7 +115,10 @@ function ToolTraceGroupInner(props: { aria-expanded={open} aria-label={open ? t("chat.tool.collapseActivity") : t("chat.tool.expandActivity")} className="grid w-full cursor-pointer select-none grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 py-1.5 text-left" - onClick={() => setOpen((prev) => !prev)} + onClick={() => { + userInteractedRef.current = true; + setOpen((prev) => !prev); + }} > @@ -177,6 +189,7 @@ export const ToolTraceGroup = memo( previous.readOnly === next.readOnly && previous.redactToolContent === next.redactToolContent && previous.isAborted === next.isAborted && + previous.defaultOpen === next.defaultOpen && previous.items.length === next.items.length && previous.items.every( (item, index) => diff --git a/crates/agent-gateway/web/src/pages/settings/SystemSettingsForm.tsx b/crates/agent-gateway/web/src/pages/settings/SystemSettingsForm.tsx index f4b6080a8..4c7d7e4fd 100644 --- a/crates/agent-gateway/web/src/pages/settings/SystemSettingsForm.tsx +++ b/crates/agent-gateway/web/src/pages/settings/SystemSettingsForm.tsx @@ -363,6 +363,34 @@ export function SystemSettingsForm(props: SettingsSectionProps) {
+
+
+
+
+ {t("settings.processDetailsExpanded")} +
+

+ {t("settings.processDetailsExpandedDesc")} +

+
+ + setSettings((prev) => + updateCustomSettings(prev, { + chatTranscript: { + ...prev.customSettings.chatTranscript, + processDetailsExpanded: + !prev.customSettings.chatTranscript.processDetailsExpanded, + }, + }), + ) + } + /> +
+
+
diff --git a/crates/agent-gateway/web/test/process-details-disclosure.test.mjs b/crates/agent-gateway/web/test/process-details-disclosure.test.mjs new file mode 100644 index 000000000..def4f88f3 --- /dev/null +++ b/crates/agent-gateway/web/test/process-details-disclosure.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; + +const rootDir = fileURLToPath(new URL("../", import.meta.url)); +const resolveSource = (relativePath) => path.join(rootDir, "src", relativePath); +const assistantBubbleSource = fs.readFileSync( + resolveSource("pages/chat/AssistantBubble.tsx"), + "utf8", +); + +let currentOpen; +let stateInitialized; +let disclosureKey; +let disclosureKeySequence = 0; + +function resetHooks({ preserveDisclosureKey = false } = {}) { + currentOpen = undefined; + stateInitialized = false; + if (!preserveDisclosureKey) { + disclosureKey = `conversation:reply-${++disclosureKeySequence}`; + } +} + +const loader = createWebModuleLoader({ + rootDir, + mocks: { + react: { + memo: (component) => component, + useId: () => "process-details-region", + useState(initial) { + if (!stateInitialized) { + currentOpen = typeof initial === "function" ? initial() : initial; + stateInitialized = true; + } + return [ + currentOpen, + (next) => { + currentOpen = typeof next === "function" ? next(currentOpen) : next; + }, + ]; + }, + }, + [resolveSource("components/icons.tsx")]: { + ChevronRight: (props) => ({ type: "ChevronRight", props }), + Lightbulb: (props) => ({ type: "Lightbulb", props }), + }, + [resolveSource("i18n/index.ts")]: { + useLocale: () => ({ t: (key) => key }), + }, + [resolveSource("pages/chat/assistant-bubble/LazyCollapse.tsx")]: { + LazyCollapse: (props) => ({ type: "LazyCollapse", props }), + }, + }, +}); + +const { ProcessDetailsDisclosure } = loader.loadModule( + "src/pages/chat/assistant-bubble/ProcessDetailsDisclosure.tsx", +); + +function render(overrides = {}) { + return ProcessDetailsDisclosure({ + disclosureKey, + hasSubstantiveAnswer: true, + expandByDefault: false, + children: () => "body", + ...overrides, + }); +} + +function toggleButton(rendered) { + return rendered.props.children[0]; +} + +test("the aggregate disclosure exposes an accessible collapsed default", () => { + resetHooks(); + const rendered = render(); + const button = toggleButton(rendered); + + assert.equal(button.type, "button"); + assert.equal(button.props.id, "process-details-region-toggle"); + assert.equal(button.props["aria-expanded"], false); + assert.equal(button.props["aria-controls"], "process-details-region"); + const region = rendered.props.children[1]; + assert.equal(region.type, "section"); + assert.equal(region.props["aria-labelledby"], "process-details-region-toggle"); +}); + +test("thinking and activity inside process details start collapsed", () => { + const source = fs.readFileSync( + fileURLToPath(new URL("../src/pages/chat/assistant-bubble/RoundContent.tsx", import.meta.url)), + "utf8", + ); + + assert.match(source, /open=\{withinProcessDetails \? false : isRunning\}/); + assert.equal((source.match(/defaultOpen=\{false\}/g) ?? []).length, 2); +}); + +test("streaming text keeps its answer identity when later process activity arrives", () => { + assert.match( + assistantBubbleSource, + /preserveStreamingText:\s*Boolean\(isStreaming\)/, + ); +}); + +test("process-only replies open automatically until the user intervenes", () => { + resetHooks(); + const automaticallyOpen = render({ hasSubstantiveAnswer: false }); + assert.equal(toggleButton(automaticallyOpen).props["aria-expanded"], true); + + toggleButton(automaticallyOpen).props.onClick(); + const manuallyClosed = render({ hasSubstantiveAnswer: false }); + assert.equal(toggleButton(manuallyClosed).props["aria-expanded"], false); + + const finalAnswerArrived = render({ hasSubstantiveAnswer: true, expandByDefault: true }); + assert.equal( + toggleButton(finalAnswerArrived).props["aria-expanded"], + false, + "a later automatic default must not override the user's choice for this response", + ); +}); + +test("the setting updates mounted replies that have no manual override", () => { + resetHooks(); + const collapsed = render(); + assert.equal(toggleButton(collapsed).props["aria-expanded"], false); + const expanded = render({ expandByDefault: true }); + assert.equal(toggleButton(expanded).props["aria-expanded"], true); +}); + +test("an untouched process waits for the stream to settle before collapsing", () => { + resetHooks(); + const processOnly = render({ hasSubstantiveAnswer: false, isStreaming: true }); + assert.equal(toggleButton(processOnly).props["aria-expanded"], true); + + const candidateAnswer = render({ hasSubstantiveAnswer: true, isStreaming: true }); + assert.equal(toggleButton(candidateAnswer).props["aria-expanded"], true); + + const laterProcessEvent = render({ hasSubstantiveAnswer: false, isStreaming: true }); + assert.equal(toggleButton(laterProcessEvent).props["aria-expanded"], true); + + const settledAnswer = render({ hasSubstantiveAnswer: true, isStreaming: false }); + assert.equal(toggleButton(settledAnswer).props["aria-expanded"], false); +}); + +test("manual state survives a virtualized unmount and remount", () => { + resetHooks(); + const expanded = render({ expandByDefault: true }); + toggleButton(expanded).props.onClick(); + + resetHooks({ preserveDisclosureKey: true }); + const remounted = render({ expandByDefault: true }); + assert.equal(toggleButton(remounted).props["aria-expanded"], false); + + const settingChanged = render({ expandByDefault: true, forceOpen: true }); + assert.equal( + toggleButton(settingChanged).props["aria-expanded"], + false, + "automatic failure/cancellation visibility must not override a restored manual choice", + ); +}); + +test("failure and cancellation force untouched process details open", () => { + resetHooks(); + const forcedOpen = render({ forceOpen: true }); + assert.equal(toggleButton(forcedOpen).props["aria-expanded"], true); +}); diff --git a/crates/agent-gateway/web/test/process-details-model.test.mjs b/crates/agent-gateway/web/test/process-details-model.test.mjs new file mode 100644 index 000000000..18b645a42 --- /dev/null +++ b/crates/agent-gateway/web/test/process-details-model.test.mjs @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader({ + rootDir: fileURLToPath(new URL("../", import.meta.url)), +}); +const model = loader.loadModule("src/lib/chat/processDetailsModel.ts"); + +function round(key, blocks, meta) { + return { key, blocks, ...(meta ? { meta } : {}) }; +} + +function kinds(partitionedRounds) { + return partitionedRounds.flatMap((entry) => entry.blocks.map((block) => block.kind)); +} + +test("plain assistant text stays outside process details", () => { + const sourceRound = round("r1", [{ kind: "text", id: "text-1", text: "Final answer" }]); + const partition = model.partitionAssistantResponse([sourceRound]); + + assert.equal(partition.hasProcessDetails, false); + assert.equal(partition.hasSubstantiveAnswer, true); + assert.deepEqual(partition.processRounds, []); + assert.equal(partition.answerRounds[0].round, sourceRound); + assert.equal(partition.answerRounds[0].blocks[0], sourceRound.blocks[0]); +}); + +test("thinking, intermediate narration, tools, and hosted search form one cross-round process", () => { + const rounds = [ + round("r1", [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { kind: "text", id: "text-1", text: "I will inspect this." }, + ]), + round("r2", [ + { kind: "tool", item: { toolCall: { id: "call-1" } } }, + { kind: "hostedSearch", item: { id: "search-1" } }, + { kind: "text", id: "text-2", text: "Here is the result." }, + ]), + ]; + + const partition = model.partitionAssistantResponse(rounds); + + assert.equal(partition.hasProcessDetails, true); + assert.equal(partition.hasSubstantiveAnswer, true); + assert.deepEqual(kinds(partition.processRounds), ["thinking", "text", "tool", "hostedSearch"]); + assert.deepEqual(kinds(partition.answerRounds), ["text"]); + assert.equal(partition.answerRounds[0].blocks[0].id, "text-2"); +}); + +test("a later process event reclassifies earlier narration and removes the final-answer boundary", () => { + const partition = model.partitionAssistantResponse([ + round("r1", [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { kind: "text", id: "text-1", text: "Possible answer" }, + { kind: "tool", item: { toolCall: { id: "call-2" } } }, + ]), + ]); + + assert.deepEqual(kinds(partition.processRounds), ["thinking", "text", "tool"]); + assert.deepEqual(partition.answerRounds, []); + assert.equal(partition.hasSubstantiveAnswer, false); +}); + +test("streaming partition preserves provisional text when a later process event arrives", () => { + const partition = model.partitionAssistantResponse( + [ + round("r1", [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { kind: "text", id: "text-1", text: "Possible answer" }, + { kind: "tool", item: { toolCall: { id: "call-2" } } }, + ]), + ], + { preserveStreamingText: true }, + ); + + assert.deepEqual(kinds(partition.processRounds), ["thinking", "tool"]); + assert.deepEqual(kinds(partition.answerRounds), ["text"]); + assert.equal(partition.hasSubstantiveAnswer, true); +}); + +test("process-only, cancelled, or whitespace-only replies remain open", () => { + const partition = model.partitionAssistantResponse([ + round("r1", [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { kind: "text", id: "text-1", text: " " }, + ]), + ]); + + assert.equal(partition.hasSubstantiveAnswer, false); + assert.equal( + model.getProcessDetailsDefaultOpen({ + hasSubstantiveAnswer: partition.hasSubstantiveAnswer, + expandByDefault: false, + }), + true, + ); +}); + +test("completed replies follow the local expand-by-default preference", () => { + assert.equal( + model.getProcessDetailsDefaultOpen({ + hasSubstantiveAnswer: true, + expandByDefault: false, + }), + false, + ); + assert.equal( + model.getProcessDetailsDefaultOpen({ + hasSubstantiveAnswer: true, + expandByDefault: true, + }), + true, + ); +}); + +test("terminal failures, unresolved tool errors, and timeouts request automatic visibility", () => { + const completedBlocks = [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { kind: "text", id: "text-1", text: "Final answer" }, + ]; + + for (const stopReason of ["aborted", "error"]) { + assert.equal( + model.shouldForceProcessDetailsOpen([round("r1", completedBlocks, { stopReason })]), + true, + ); + } + assert.equal( + model.shouldForceProcessDetailsOpen([ + round("r1", [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { + kind: "tool", + item: { + toolCall: { id: "call-1" }, + toolResult: { isError: true, details: {} }, + }, + }, + ]), + ]), + true, + ); + assert.equal( + model.shouldForceProcessDetailsOpen([ + round("r1", [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { + kind: "tool", + item: { + toolCall: { id: "question-1" }, + toolResult: { + isError: false, + details: { kind: "ask_user_question", timedOut: true }, + }, + }, + }, + { kind: "text", id: "text-1", text: "Final answer" }, + ]), + ]), + true, + ); + assert.equal(model.shouldForceProcessDetailsOpen([round("r1", completedBlocks)]), false); +}); + +test("a recovered tool error does not override the final-answer disclosure preference", () => { + assert.equal( + model.shouldForceProcessDetailsOpen([ + round("r1", [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { + kind: "tool", + item: { + toolCall: { id: "call-1" }, + toolResult: { isError: true, details: {} }, + }, + }, + { kind: "text", id: "text-1", text: "Recovered final answer" }, + ]), + ]), + false, + ); +}); diff --git a/crates/agent-gateway/web/test/process-details-row-estimate.test.mjs b/crates/agent-gateway/web/test/process-details-row-estimate.test.mjs new file mode 100644 index 000000000..6cfb293e0 --- /dev/null +++ b/crates/agent-gateway/web/test/process-details-row-estimate.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader({ + rootDir: fileURLToPath(new URL("../", import.meta.url)), +}); +const { estimateAssistantResponseRowHeight } = loader.loadModule( + "src/lib/transcript-virtual/assistantResponseEstimate.ts", +); + +function round(blocks, meta) { + return { blocks, ...(meta ? { meta } : {}) }; +} + +test("expanded process estimates keep nested thinking collapsed", () => { + const rounds = [ + round([ + { kind: "thinking", text: "reasoning ".repeat(500) }, + { kind: "tool", item: { toolCall: { name: "Read" } } }, + { kind: "text", text: "Final answer" }, + ]), + ]; + + const collapsed = estimateAssistantResponseRowHeight(rounds, false); + const expanded = estimateAssistantResponseRowHeight(rounds, true); + const shortThinking = estimateAssistantResponseRowHeight( + [ + round([ + { kind: "thinking", text: "reasoning" }, + { kind: "tool", item: { toolCall: { name: "Read" } } }, + { kind: "text", text: "Final answer" }, + ]), + ], + true, + ); + + assert.ok(expanded > collapsed); + assert.equal(expanded, shortThinking); +}); + +test("process-only replies estimate as open regardless of the setting", () => { + const rounds = [ + round([ + { kind: "thinking", text: "still working ".repeat(100) }, + { kind: "tool", item: { toolCall: { name: "Read" } } }, + ]), + ]; + + assert.equal( + estimateAssistantResponseRowHeight(rounds, false), + estimateAssistantResponseRowHeight(rounds, true), + ); +}); + +test("artifacts outside the disclosure retain a collapsed-height reserve", () => { + const base = [ + round([ + { kind: "thinking", text: "plan" }, + { kind: "text", text: "Done" }, + ]), + ]; + const withArtifacts = [ + round([ + { kind: "thinking", text: "plan" }, + { + kind: "tool", + item: { + toolCall: { name: "Write" }, + toolResult: { isError: false, details: { path: "README.md" } }, + }, + }, + { + kind: "tool", + item: { + toolCall: { name: "Image" }, + toolResult: { isError: false, details: { kind: "display_image" } }, + }, + }, + { kind: "text", text: "Done" }, + ]), + ]; + + assert.ok( + estimateAssistantResponseRowHeight(withArtifacts, false) > + estimateAssistantResponseRowHeight(base, false), + ); +}); + +test("answering, failed, and timed-out process details use the correct open estimate", () => { + const completedBlocks = [ + { kind: "thinking", text: "reasoning ".repeat(200) }, + { kind: "text", text: "Final answer" }, + ]; + const normal = [round(completedBlocks)]; + const collapsed = estimateAssistantResponseRowHeight(normal, false); + const expanded = estimateAssistantResponseRowHeight(normal, true); + assert.ok(expanded > collapsed); + + const failed = [round(completedBlocks, { stopReason: "error" })]; + assert.equal( + estimateAssistantResponseRowHeight(failed, false), + estimateAssistantResponseRowHeight(failed, true), + ); + + const timedOut = [ + round([ + { kind: "thinking", text: "reasoning ".repeat(200) }, + { + kind: "tool", + item: { + toolCall: { name: "AskUserQuestion" }, + toolResult: { + isError: false, + details: { kind: "ask_user_question", timedOut: true }, + }, + }, + }, + { kind: "text", text: "Final answer" }, + ]), + ]; + assert.equal( + estimateAssistantResponseRowHeight(timedOut, false), + estimateAssistantResponseRowHeight(timedOut, true), + ); +}); diff --git a/crates/agent-gateway/web/test/process-details-settings.test.mjs b/crates/agent-gateway/web/test/process-details-settings.test.mjs new file mode 100644 index 000000000..57bc6071c --- /dev/null +++ b/crates/agent-gateway/web/test/process-details-settings.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader({ + rootDir: fileURLToPath(new URL("../", import.meta.url)), +}); +const settings = loader.loadModule("src/lib/settings/index.ts"); +const sync = loader.loadModule("src/lib/settings/sync.ts"); + +test("process detail expansion defaults off and survives transcript resizing", () => { + assert.deepEqual(settings.normalizeChatTranscriptSettings(undefined), { + width: 768, + processDetailsExpanded: false, + }); + assert.deepEqual( + settings.normalizeChatTranscriptSettings({ + width: 920.4, + processDetailsExpanded: true, + }), + { width: 920, processDetailsExpanded: true }, + ); + + const current = settings.normalizeSettings({ + customSettings: { + chatTranscript: { width: 768, processDetailsExpanded: true }, + }, + }); + const resized = settings.updateChatTranscriptWidth(current, 960); + assert.deepEqual(resized.customSettings.chatTranscript, { + width: 960, + processDetailsExpanded: true, + }); +}); + +test("Gateway synchronization cannot overwrite the local disclosure preference", () => { + const current = settings.normalizeSettings({ + customSettings: { + chatTranscript: { width: 920, processDetailsExpanded: true }, + }, + }); + const remote = settings.normalizeSettings({ + customSettings: { + chatTranscript: { width: 1100, processDetailsExpanded: false }, + }, + }); + + const payload = sync.buildGatewaySettingsSyncPayload(remote); + const synced = sync.applyGatewaySettingsSyncPayload(current, payload); + + assert.deepEqual(payload.customSettings.chatTranscript, { + width: 768, + processDetailsExpanded: false, + }); + assert.deepEqual(synced.customSettings.chatTranscript, { + width: 920, + processDetailsExpanded: true, + }); +}); diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 1d5a9014a..a321c1d11 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -168,6 +168,7 @@ export const translations: Record> = { "chat.stopGeneration": "停止生成", "chat.thinking": "思考中", "chat.thinkingProcess": "思考过程", + "chat.processDetails": "过程详情", "chat.retryDetailsToggle": "重试详情 ({count})", "chat.retryAttemptLabel": "第 {attempt}/{maxAttempts} 次重试", "chat.runtime.thinkingOn": "Thinking 已开启", @@ -1189,6 +1190,9 @@ export const translations: Record> = { /* ── Settings System ── */ "settings.appearance": "外观主题", "settings.appearanceDesc": "选择应用的颜色主题,偏好会自动保存。", + "settings.processDetailsExpanded": "默认展开过程详情", + "settings.processDetailsExpandedDesc": + "开启后,思考和工具调用默认展开;关闭后,生成正式回答时自动收起,仍可手动查看。", "settings.systemProxy": "应用代理", "settings.systemProxyEnable": "启用应用代理", "settings.systemProxyDesc": @@ -2436,6 +2440,7 @@ export const translations: Record> = { "chat.stopGeneration": "Stop Generation", "chat.thinking": "Thinking", "chat.thinkingProcess": "Thinking Process", + "chat.processDetails": "Process details", "chat.retryDetailsToggle": "Retry details ({count})", "chat.retryAttemptLabel": "Retry {attempt}/{maxAttempts}", "chat.runtime.thinkingOn": "Thinking enabled", @@ -3498,6 +3503,9 @@ export const translations: Record> = { "settings.appearance": "Appearance", "settings.appearanceDesc": "Choose the color theme for the application. Your preference will be saved automatically.", + "settings.processDetailsExpanded": "Expand process details by default", + "settings.processDetailsExpandedDesc": + "Keep reasoning and tool activity expanded by default. When disabled, they collapse as the final answer appears and can still be opened manually.", "settings.systemProxy": "App Proxy", "settings.systemProxyEnable": "Enable app proxy", "settings.systemProxyDesc": diff --git a/crates/agent-gui/src/lib/chat/processDetailsDisclosureState.ts b/crates/agent-gui/src/lib/chat/processDetailsDisclosureState.ts new file mode 100644 index 000000000..eced760f6 --- /dev/null +++ b/crates/agent-gui/src/lib/chat/processDetailsDisclosureState.ts @@ -0,0 +1,21 @@ +const MAX_MANUAL_PROCESS_DETAILS_STATES = 1_000; + +const manualOpenByDisclosureKey = new Map(); + +export function readManualProcessDetailsOpen(disclosureKey: string): boolean | undefined { + const value = manualOpenByDisclosureKey.get(disclosureKey); + if (value === undefined) return undefined; + manualOpenByDisclosureKey.delete(disclosureKey); + manualOpenByDisclosureKey.set(disclosureKey, value); + return value; +} + +export function writeManualProcessDetailsOpen(disclosureKey: string, open: boolean): void { + manualOpenByDisclosureKey.delete(disclosureKey); + manualOpenByDisclosureKey.set(disclosureKey, open); + while (manualOpenByDisclosureKey.size > MAX_MANUAL_PROCESS_DETAILS_STATES) { + const oldestKey = manualOpenByDisclosureKey.keys().next().value; + if (oldestKey === undefined) break; + manualOpenByDisclosureKey.delete(oldestKey); + } +} diff --git a/crates/agent-gui/src/lib/chat/processDetailsModel.ts b/crates/agent-gui/src/lib/chat/processDetailsModel.ts new file mode 100644 index 000000000..ca4acc347 --- /dev/null +++ b/crates/agent-gui/src/lib/chat/processDetailsModel.ts @@ -0,0 +1,127 @@ +export type AssistantResponseBlockLike = { + kind: string; + text?: string; + item?: unknown; +}; + +export type AssistantResponseRoundLike = { + blocks: readonly AssistantResponseBlockLike[]; + meta?: { stopReason?: unknown }; +}; + +export type PartitionedAssistantRound = { + round: TRound; + blocks: TBlock[]; +}; + +export type AssistantResponsePartition = { + processRounds: PartitionedAssistantRound[]; + answerRounds: PartitionedAssistantRound[]; + hasProcessDetails: boolean; + hasSubstantiveAnswer: boolean; +}; + +export type PartitionAssistantResponseOptions = { + preserveStreamingText?: boolean; +}; + +export function isProcessDetailsBlock(block: AssistantResponseBlockLike): boolean { + return block.kind === "thinking" || block.kind === "tool" || block.kind === "hostedSearch"; +} + +export function partitionAssistantResponse< + TBlock extends AssistantResponseBlockLike, + TRound extends { blocks: readonly TBlock[] }, +>( + rounds: readonly TRound[], + options: PartitionAssistantResponseOptions = {}, +): AssistantResponsePartition { + let blockPosition = 0; + let lastProcessPosition = -1; + + for (const round of rounds) { + for (const block of round.blocks) { + if (isProcessDetailsBlock(block)) { + lastProcessPosition = blockPosition; + } + blockPosition += 1; + } + } + + const processRounds: PartitionedAssistantRound[] = []; + const answerRounds: PartitionedAssistantRound[] = []; + let hasSubstantiveAnswer = false; + blockPosition = 0; + + for (const round of rounds) { + const processBlocks: TBlock[] = []; + const answerBlocks: TBlock[] = []; + + for (const block of round.blocks) { + if ( + blockPosition <= lastProcessPosition && + !(options.preserveStreamingText && block.kind === "text") + ) { + processBlocks.push(block); + } else { + answerBlocks.push(block); + if (block.kind === "text" && /\S/.test(block.text ?? "")) { + hasSubstantiveAnswer = true; + } + } + blockPosition += 1; + } + + if (processBlocks.length > 0) { + processRounds.push({ round, blocks: processBlocks }); + } + if (answerBlocks.length > 0) { + answerRounds.push({ round, blocks: answerBlocks }); + } + } + + return { + processRounds, + answerRounds, + hasProcessDetails: lastProcessPosition >= 0, + hasSubstantiveAnswer, + }; +} + +export function getProcessDetailsDefaultOpen(input: { + hasSubstantiveAnswer: boolean; + expandByDefault: boolean; +}): boolean { + return !input.hasSubstantiveAnswer || input.expandByDefault; +} + +export function shouldForceProcessDetailsOpen( + rounds: readonly AssistantResponseRoundLike[], +): boolean { + const { hasSubstantiveAnswer } = partitionAssistantResponse(rounds); + + for (const round of rounds) { + const stopReason = round.meta?.stopReason; + if (stopReason === "aborted" || stopReason === "error") return true; + + for (const block of round.blocks) { + if (block.kind !== "tool" || !block.item || typeof block.item !== "object") continue; + const toolResult = (block.item as { toolResult?: unknown }).toolResult; + if (!toolResult || typeof toolResult !== "object") continue; + const result = toolResult as { isError?: unknown; details?: unknown }; + // A tool-level error can be recovered by a later tool call. Once a + // substantive answer exists, that recovered error must not override the + // user's disclosure preference. Without an answer, keep the failure + // visible even if the user collapsed the active process manually. + if (result.isError === true && !hasSubstantiveAnswer) return true; + if ( + result.details && + typeof result.details === "object" && + (result.details as { timedOut?: unknown }).timedOut === true + ) { + return true; + } + } + } + return false; +} diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index d924432fc..0419f5e1e 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -162,6 +162,7 @@ export { DEFAULT_CHAT_TRANSCRIPT_WIDTH, MAX_CHAT_TRANSCRIPT_WIDTH, MIN_CHAT_TRAN export type ChatTranscriptSettings = { width: number; + processDetailsExpanded: boolean; }; export type CustomSettings = { @@ -2158,6 +2159,7 @@ export function normalizeChatTranscriptSettings(input: unknown): ChatTranscriptS MAX_CHAT_TRANSCRIPT_WIDTH, DEFAULT_CHAT_TRANSCRIPT_WIDTH, ), + processDetailsExpanded: obj.processDetailsExpanded === true, }; } @@ -2493,7 +2495,9 @@ export function getRightDockProjectState( export function updateChatTranscriptWidth(prev: AppSettings, width: number): AppSettings { const nextWidth = normalizeChatTranscriptSettings({ width }).width; if (prev.customSettings.chatTranscript.width === nextWidth) return prev; - return updateCustomSettings(prev, { chatTranscript: { width: nextWidth } }); + return updateCustomSettings(prev, { + chatTranscript: { ...prev.customSettings.chatTranscript, width: nextWidth }, + }); } export function updateRightDockWidth(prev: AppSettings, width: number): AppSettings { diff --git a/crates/agent-gui/src/lib/settings/sync.ts b/crates/agent-gui/src/lib/settings/sync.ts index cd9c642f8..97d3f6e91 100644 --- a/crates/agent-gui/src/lib/settings/sync.ts +++ b/crates/agent-gui/src/lib/settings/sync.ts @@ -468,12 +468,15 @@ function syncableCustomSettings( projectsCollapsed: false, recentCollapsed: false, }, - // Typography, scale, and transcript width are local UI preferences; fixed - // defaults prevent visual preferences from being broadcast through the gateway. + // Typography, scale, and transcript presentation are local UI preferences; + // fixed defaults prevent visual preferences from being broadcast through the gateway. interfaceFontFamily: "", chatFontFamily: "", codeFontFamily: "", - chatTranscript: { width: DEFAULT_CHAT_TRANSCRIPT_WIDTH }, + chatTranscript: { + width: DEFAULT_CHAT_TRANSCRIPT_WIDTH, + processDetailsExpanded: false, + }, fontScale: { sidebar: 1, chat: 1, rightDock: 1 }, }; } @@ -1213,7 +1216,8 @@ export function applyGatewaySettingsSyncPayload( ) : current.customSettings.rightDock, chatSidebar: current.customSettings.chatSidebar, - // Typography, scale, and transcript width are local UI preferences, never gateway-synced. + // Typography, scale, and transcript presentation are local UI preferences, + // never gateway-synced. interfaceFontFamily: current.customSettings.interfaceFontFamily, chatFontFamily: current.customSettings.chatFontFamily, codeFontFamily: current.customSettings.codeFontFamily, diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 6974cae64..9cd8df464 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -2009,6 +2009,9 @@ export function ChatPage(props: ChatPageProps) { isCompactionRunning={isCompactionRunning} bottomReservePx={composerOverlayHeight} contentWidth={settings.customSettings.chatTranscript.width} + processDetailsExpanded={ + settings.customSettings.chatTranscript.processDetailsExpanded + } onContentWidthChange={handleChatTranscriptWidthChange} onOpenFileLink={handleOpenChatFileLink} onResendFromEdit={handleResendFromEdit} diff --git a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx index 85236e056..79024a064 100644 --- a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx +++ b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx @@ -3,15 +3,87 @@ import { memo, type ReactNode } from "react"; import type { ChatFileLink } from "../../../lib/chat/chatFileLinks"; import type { RetryAttemptRecord } from "../../../lib/chat/conversation/liveTranscriptStore"; import { VIBING_STATUS } from "../../../lib/chat/page/chatPageHelpers"; -import type { AssistantUnitRow } from "../transcript/rowModel"; +import type { AssistantProcessRenderUnit, AssistantUnitRow } from "../transcript/rowModel"; import { AssistantAvatar } from "./assistant-bubble/AssistantAvatar"; +import { ProcessDetailsDisclosure } from "./assistant-bubble/ProcessDetailsDisclosure"; import { RetryDetailsBlock, RoundBlockContent } from "./assistant-bubble/RoundContent"; import { AssistantStatus, CompactingText, VibingText } from "./assistant-bubble/StatusText"; +import { + getNativeDisplayImagePayload, + NativeDisplayImageBlock, +} from "./assistant-bubble/ToolImages"; import { UsagePanel } from "./assistant-bubble/UsagePanel"; export { AssistantAvatar } from "./assistant-bubble/AssistantAvatar"; +const AssistantProcessDetails = memo(function AssistantProcessDetails(props: { + conversationId: string; + unit: AssistantProcessRenderUnit; + row: AssistantUnitRow; + processDetailsExpanded: boolean; + showUsage?: boolean; + usageContextWindow?: number; + workdir?: string; + onOpenFileLink?: (link: ChatFileLink) => void; +}) { + const { + conversationId, + unit, + row, + processDetailsExpanded, + showUsage, + usageContextWindow, + workdir, + onOpenFileLink, + } = props; + const displayImages = unit.blocks.flatMap((entry) => { + if (entry.block.kind !== "tool") return []; + const payload = getNativeDisplayImagePayload(entry.block.item); + return payload ? [{ key: entry.key, payload }] : []; + }); + return ( + <> + + {() => + unit.blocks.map((entry, index) => ( +
+ + {entry.isRoundTail && showUsage ? ( + + ) : null} +
+ )) + } +
+ + {displayImages.map(({ key, payload }) => ( + + ))} + + ); +}); + export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: { + conversationId: string; row: AssistantUnitRow; showUsage?: boolean; usageContextWindow?: number; @@ -19,10 +91,12 @@ export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: { isCompactionRunning: boolean; toolStatus: string | null; retryAttempts?: RetryAttemptRecord[]; + processDetailsExpanded: boolean; workdir?: string; onOpenFileLink?: (link: ChatFileLink) => void; }) { const { + conversationId, row, showUsage, usageContextWindow, @@ -30,6 +104,7 @@ export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: { isCompactionRunning, toolStatus, retryAttempts, + processDetailsExpanded, workdir, onOpenFileLink, } = props; @@ -58,7 +133,7 @@ export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: { } } else if ( row.mutable && - unit.kind === "block" && + (unit.kind === "block" || unit.kind === "process") && toolStatus && (!unit.hasRunningToolCall || isCompactionRunning || isVibingStatus) ) { @@ -108,6 +183,19 @@ export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: { /> ) : null} + {unit.kind === "process" ? ( + + ) : null} + {unit.kind === "block" && unit.isRoundTail && showUsage ? ( ) : null} diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/HostedSearchGroupView.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/HostedSearchGroupView.tsx index f08080319..b779b1bf7 100644 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/HostedSearchGroupView.tsx +++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/HostedSearchGroupView.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { ChevronRight, Globe } from "../../../../components/icons"; import { useLocale } from "../../../../i18n"; @@ -83,12 +83,20 @@ function getHostedSearchCountLabel(count: number, t: (key: string) => string) { export function HostedSearchGroupView({ items, isLive = false, + defaultOpen = false, }: { items: HostedSearchBlock[]; isLive?: boolean; + defaultOpen?: boolean; }) { const { t } = useLocale(); - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(defaultOpen); + const userInteractedRef = useRef(false); + useEffect(() => { + if (!userInteractedRef.current) { + setOpen(defaultOpen); + } + }, [defaultOpen]); const queries = useMemo(() => getUniqueHostedSearchQueries(items), [items]); const sources = useMemo(() => getUniqueHostedSearchSources(items), [items]); const visibleSources = sources.slice(0, 10); @@ -104,7 +112,10 @@ export function HostedSearchGroupView({ aria-expanded={open} aria-label={open ? t("chat.search.collapseActivity") : t("chat.search.expandActivity")} className="group/search flex w-full cursor-pointer select-none items-center justify-between gap-3 py-1.5 text-left" - onClick={() => setOpen((prev) => !prev)} + onClick={() => { + userInteractedRef.current = true; + setOpen((prev) => !prev); + }} >
diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ProcessDetailsDisclosure.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ProcessDetailsDisclosure.tsx new file mode 100644 index 000000000..60d33fb59 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ProcessDetailsDisclosure.tsx @@ -0,0 +1,69 @@ +import { memo, type ReactNode, useId, useState } from "react"; + +import { ChevronRight, Lightbulb } from "../../../../components/icons"; +import { useLocale } from "../../../../i18n"; +import { + readManualProcessDetailsOpen, + writeManualProcessDetailsOpen, +} from "../../../../lib/chat/processDetailsDisclosureState"; +import { getProcessDetailsDefaultOpen } from "../../../../lib/chat/processDetailsModel"; +import { LazyCollapse } from "./LazyCollapse"; + +export const ProcessDetailsDisclosure = memo(function ProcessDetailsDisclosure(props: { + disclosureKey: string; + hasSubstantiveAnswer: boolean; + isStreaming?: boolean; + expandByDefault: boolean; + forceOpen?: boolean; + retainWhileClosed?: boolean; + children: () => ReactNode; +}) { + const { + disclosureKey, + hasSubstantiveAnswer, + isStreaming = false, + expandByDefault, + forceOpen = false, + retainWhileClosed = false, + children, + } = props; + const { t } = useLocale(); + const regionId = useId(); + const toggleId = `${regionId}-toggle`; + const automaticOpen = + forceOpen || + getProcessDetailsDefaultOpen({ + hasSubstantiveAnswer: hasSubstantiveAnswer && !isStreaming, + expandByDefault, + }); + const [manualOpen, setManualOpen] = useState(() => readManualProcessDetailsOpen(disclosureKey)); + const open = manualOpen ?? automaticOpen; + + return ( +
+ +
+ + {() =>
{children()}
} +
+
+
+ ); +}); diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx index 4e1d84ef9..ca259d186 100644 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx +++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx @@ -13,6 +13,25 @@ import { MemoToolCallItem } from "./ToolCallItem"; import { getNativeDisplayImagePayload, NativeDisplayImageBlock } from "./ToolImages"; import { ToolTraceGroup } from "./ToolTraceGroup"; +const ThinkingMarkdown = memo(function ThinkingMarkdown(props: { + text: string; + renderMode: "streaming" | "static"; + workdir?: string; + onOpenFileLink?: (link: ChatFileLink) => void; +}) { + const { text, renderMode, workdir, onOpenFileLink } = props; + return ( + + ); +}); + const ThinkingBlock = memo(function ThinkingBlock({ text, open, @@ -66,11 +85,9 @@ const ThinkingBlock = memo(function ThinkingBlock({ {() => (
- @@ -138,6 +155,7 @@ export const RoundBlockContent = memo(function RoundBlockContent(props: { thinkingOpen: boolean; isLatestThinking: boolean; isAborted: boolean; + withinProcessDetails?: boolean; workdir?: string; onOpenFileLink?: (link: ChatFileLink) => void; }) { @@ -150,6 +168,7 @@ export const RoundBlockContent = memo(function RoundBlockContent(props: { thinkingOpen, isLatestThinking, isAborted, + withinProcessDetails = false, workdir, onOpenFileLink, } = props; @@ -160,7 +179,7 @@ export const RoundBlockContent = memo(function RoundBlockContent(props: { content = ( ; - } else if (block.item.toolCall.name === "Image" && !block.item.toolResult?.isError) { + } else if ( + !withinProcessDetails && + block.item.toolCall.name === "Image" && + !block.item.toolResult?.isError + ) { content = null; } else { content = ( @@ -190,6 +213,7 @@ export const RoundBlockContent = memo(function RoundBlockContent(props: { items={block.items} isAborted={isAborted} runningToolCallIds={isLive ? runningToolCallIds : []} + defaultOpen={false} /> ); } else if (block.kind === "hostedSearch" || block.kind === "hostedSearchGroup") { @@ -197,6 +221,7 @@ export const RoundBlockContent = memo(function RoundBlockContent(props: { ); } else if (block.text.trim()) { 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..37e91adf5 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 @@ -1,4 +1,4 @@ -import { memo, useMemo, useState } from "react"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; import { ChevronRight, Terminal } from "../../../../components/icons"; import { useLocale } from "../../../../i18n"; @@ -19,8 +19,9 @@ function ToolTraceGroupInner(props: { items: ToolTraceItem[]; runningToolCallIds?: string[]; isAborted?: boolean; + defaultOpen?: boolean; }) { - const { items, runningToolCallIds = [], isAborted = false } = props; + const { items, runningToolCallIds = [], isAborted = false, defaultOpen = false } = props; const { t } = useLocale(); const counts = useMemo( () => getToolGroupCounts(items, runningToolCallIds), @@ -34,7 +35,14 @@ function ToolTraceGroupInner(props: { [allBash, dominantToolName], ); const ToolIcon = allBash ? Terminal : meta.Icon; - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(defaultOpen); + const userInteractedRef = useRef(false); + + useEffect(() => { + if (!userInteractedRef.current) { + setOpen(defaultOpen); + } + }, [defaultOpen]); const statusLabel = counts.failed > 0 @@ -58,7 +66,10 @@ function ToolTraceGroupInner(props: { aria-expanded={open} aria-label={open ? t("chat.tool.collapseActivity") : t("chat.tool.expandActivity")} className="grid w-full cursor-pointer select-none grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 py-1.5 text-left" - onClick={() => setOpen((prev) => !prev)} + onClick={() => { + userInteractedRef.current = true; + setOpen((prev) => !prev); + }} > @@ -130,5 +141,6 @@ export const ToolTraceGroup = memo( item === next.items[index] || areToolTraceItemsEqual(item, next.items[index]), ) && previous.isAborted === next.isAborted && + previous.defaultOpen === next.defaultOpen && areRunningIdsEqual(previous.runningToolCallIds, next.runningToolCallIds), ); diff --git a/crates/agent-gui/src/pages/chat/transcript/AssistantRenderUnit.tsx b/crates/agent-gui/src/pages/chat/transcript/AssistantRenderUnit.tsx index 3b775eb27..8d96f7b02 100644 --- a/crates/agent-gui/src/pages/chat/transcript/AssistantRenderUnit.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/AssistantRenderUnit.tsx @@ -11,6 +11,7 @@ import { AssistantRowFooter } from "./RowActions"; import type { AssistantFooterRenderUnit, AssistantUnitRow } from "./rowModel"; export type AssistantRenderUnitProps = { + conversationId: string; row: AssistantUnitRow; showUsage?: boolean; usageContextWindow?: number; @@ -18,6 +19,7 @@ export type AssistantRenderUnitProps = { isCompactionRunning: boolean; toolStatus: string | null; retryAttempts?: RetryAttemptRecord[]; + processDetailsExpanded: boolean; workdir?: string; onOpenFileLink?: (link: ChatFileLink) => void; onResendFromEdit: ( @@ -75,6 +77,7 @@ export const AssistantRenderUnit = memo(function AssistantRenderUnit( props: AssistantRenderUnitProps, ) { const { + conversationId, row, showUsage, usageContextWindow, @@ -82,6 +85,7 @@ export const AssistantRenderUnit = memo(function AssistantRenderUnit( isCompactionRunning, toolStatus, retryAttempts, + processDetailsExpanded, workdir, onOpenFileLink, onResendFromEdit, @@ -104,6 +108,7 @@ export const AssistantRenderUnit = memo(function AssistantRenderUnit( return (
diff --git a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx index bf3fb1335..fd57d35d1 100644 --- a/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/ChatTranscript.tsx @@ -53,6 +53,7 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript isCompactionRunning, bottomReservePx = 0, contentWidth, + processDetailsExpanded, onContentWidthChange, onOpenFileLink, onResendFromEdit, @@ -316,6 +317,7 @@ export const ChatTranscript = memo(function ChatTranscript(props: ChatTranscript liveTranscriptStore={liveTranscriptStore} scrollViewport={scrollViewport} layoutWidth={contentWidth} + processDetailsExpanded={processDetailsExpanded} isViewportFollowing={scrollFollowHandle.isFollowing} isSending={isSending} isAgentMode={isAgentMode} diff --git a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx index 6f775dc14..162af49bf 100644 --- a/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx +++ b/crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx @@ -41,11 +41,18 @@ 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-process-details-v1"; -function buildVersionedTranscriptLayoutKey(viewportWidth: number, contentWidth: number) { +function buildVersionedTranscriptLayoutKey( + viewportWidth: number, + contentWidth: number, + processDetailsExpanded: boolean, +) { const layoutKey = buildTranscriptLayoutKey(viewportWidth, contentWidth); - return layoutKey ? `${layoutKey}:${TRANSCRIPT_MEASUREMENT_LAYOUT_VERSION}` : ""; + if (!layoutKey) return ""; + return `${layoutKey}:${TRANSCRIPT_MEASUREMENT_LAYOUT_VERSION}:process-details-${ + processDetailsExpanded ? "expanded" : "collapsed" + }`; } // Measured row heights survive conversation switches: saved on unmount, @@ -108,6 +115,7 @@ export type TranscriptListProps = { liveTranscriptStore: LiveTranscriptStore; scrollViewport: HTMLDivElement | null; layoutWidth: number; + processDetailsExpanded: boolean; // Whether the scroll-follow engine is attached to the bottom; gates the // virtualizer's resize-compensation carve-out for live-row growth. isViewportFollowing?: () => boolean; @@ -137,8 +145,9 @@ 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. +// use one aggregate process-details unit followed by block-level answer units, +// so a long final answer never becomes one giant row; only its mutable live +// tail stays force-mounted. export const TranscriptList = memo(function TranscriptList(props: TranscriptListProps) { const { conversationId, @@ -146,6 +155,7 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList liveTranscriptStore, scrollViewport, layoutWidth, + processDetailsExpanded, isViewportFollowing, isSending, isAgentMode, @@ -178,8 +188,8 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList ); const { rows, liveStartIndex } = useMemo( - () => rowModel.build(historyItems, { ...liveState, isSending }), - [rowModel, historyItems, liveState, isSending], + () => rowModel.build(historyItems, { ...liveState, isSending }, processDetailsExpanded), + [rowModel, historyItems, liveState, isSending, processDetailsExpanded], ); const rowsRef = useRef(rows); @@ -266,7 +276,11 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList (scrollViewport ? transcriptMeasurementsLru.restore( conversationId, - buildVersionedTranscriptLayoutKey(scrollViewport.clientWidth, layoutWidth), + buildVersionedTranscriptLayoutKey( + scrollViewport.clientWidth, + layoutWidth, + processDetailsExpanded, + ), ) : null) ?? [], ); @@ -295,6 +309,16 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList rangeExtractor: extractVirtualRange, }); + const previousProcessDetailsExpandedRef = useRef(processDetailsExpanded); + useLayoutEffect(() => { + if (previousProcessDetailsExpandedRef.current === processDetailsExpanded) return; + previousProcessDetailsExpandedRef.current = processDetailsExpanded; + // Unmounted process rows retain their last measured height in the + // virtualizer. Clear that cache so every row immediately uses the estimate + // for the new presentation preference instead of correcting on first view. + virtualizer.measure(); + }, [processDetailsExpanded, virtualizer]); + // 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 @@ -484,7 +508,11 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList if (!scrollViewport) return; transcriptMeasurementsLru.save( conversationId, - buildVersionedTranscriptLayoutKey(scrollViewport.clientWidth, layoutWidth), + buildVersionedTranscriptLayoutKey( + scrollViewport.clientWidth, + layoutWidth, + processDetailsExpanded, + ), virtualizer.takeSnapshot(), ); }; @@ -518,6 +546,7 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList body = (
& { + key: string; +}; + +export type AssistantProcessRenderUnit = { + kind: "process"; + blocks: AssistantProcessBlock[]; + hasRunningToolCall: boolean; + hasSubstantiveAnswer: boolean; + forceOpen: boolean; +}; + export type AssistantPlaceholderRenderUnit = { kind: "placeholder"; showFallbackStatus: boolean; @@ -66,6 +83,7 @@ export type AssistantFooterRenderUnit = { export type AssistantRenderUnit = | AssistantBlockRenderUnit + | AssistantProcessRenderUnit | AssistantPlaceholderRenderUnit | AssistantFooterRenderUnit; @@ -100,8 +118,11 @@ export type LiveTailInput = LiveTranscriptState & { }; function buildReplyText(rounds: (UiRound | LiveRound)[]): string { - return rounds - .map((round) => getRoundText(round).trim()) + const partition = partitionAssistantResponse( + rounds, + ); + return partition.answerRounds + .map(({ blocks }) => getRoundText({ blocks }).trim()) .filter((text) => text.length > 0) .join("\n\n"); } @@ -236,6 +257,19 @@ function sameGroupedBlock(previous: GroupedRoundBlock, next: GroupedRoundBlock) ); } +function sameProcessBlock(previous: AssistantProcessBlock, next: AssistantProcessBlock) { + return ( + previous.key === next.key && + sameGroupedBlock(previous.block, next.block) && + previous.roundMeta === next.roundMeta && + sameStringArray(previous.runningToolCallIds, next.runningToolCallIds) && + previous.thinkingOpen === next.thinkingOpen && + previous.isLatestThinking === next.isLatestThinking && + previous.isRoundTail === next.isRoundTail && + previous.hasRunningToolCall === next.hasRunningToolCall + ); +} + function canReuseLiveUnit(previous: AssistantUnitRow, next: AssistantUnitRow) { if (previous.mutable || next.mutable) return false; if ( @@ -250,11 +284,26 @@ function canReuseLiveUnit(previous: AssistantUnitRow, next: AssistantUnitRow) { previous.compacted !== next.compacted || previous.showAvatar !== next.showAvatar || previous.isAborted !== next.isAborted || - previous.unit.kind !== "block" || - next.unit.kind !== "block" + previous.unit.kind !== next.unit.kind ) { return false; } + if (previous.unit.kind === "process" && next.unit.kind === "process") { + const nextBlocks = next.unit.blocks; + return ( + previous.unit.hasRunningToolCall === next.unit.hasRunningToolCall && + previous.unit.hasSubstantiveAnswer === next.unit.hasSubstantiveAnswer && + previous.unit.forceOpen === next.unit.forceOpen && + previous.unit.blocks.length === next.unit.blocks.length && + previous.unit.blocks.every((block, index) => { + const nextBlock = nextBlocks[index]; + return Boolean(nextBlock && sameProcessBlock(block, nextBlock)); + }) + ); + } + if (previous.unit.kind !== "block" || next.unit.kind !== "block") { + return false; + } return ( sameGroupedBlock(previous.unit.block, next.unit.block) && previous.unit.roundMeta === next.unit.roundMeta && @@ -276,6 +325,7 @@ type BuildAssistantUnitsInput = { replyText: string; retryTarget: RenderUserMessage | null; anchorUserKey: string | null; + processDetailsExpanded: boolean; liveUnitCache?: Map; }; @@ -290,16 +340,105 @@ function buildAssistantUnits(input: BuildAssistantUnitsInput): AssistantUnitRow[ replyText, retryTarget, anchorUserKey, + processDetailsExpanded, liveUnitCache, } = input; const latestTodoItem = findLatestTodoItem(rounds); const isAborted = rounds.some((round) => round.meta?.stopReason === "aborted"); const rows: AssistantUnitRow[] = []; + const partition = partitionAssistantResponse( + rounds, + { preserveStreamingText: live }, + ); + // Keep provisional text in stable answer rows while streaming. A later tool + // can extend the process without deleting that live tail; settled history + // still uses the final last-process boundary. + const forceProcessDetailsOpen = shouldForceProcessDetailsOpen(rounds); + const prepareBlocks = (blocks: UiRound["blocks"]) => + groupRoundBlocks(blocks).filter((block) => isVisibleGroupedBlock(block, latestTodoItem)); + const answerParts = partition.answerRounds.map(({ round, blocks }) => ({ + round, + groupedBlocks: prepareBlocks(blocks), + })); + const answerBlocksByRound = new Map( + answerParts.map(({ round, groupedBlocks }) => [round, groupedBlocks] as const), + ); + const processBlocks: AssistantProcessBlock[] = []; + + for (const { round, blocks } of partition.processRounds) { + const groupedBlocks = prepareBlocks(blocks); + const runningToolCallIds = "runningToolCallIds" in round ? round.runningToolCallIds : []; + const roundHasRunningToolCall = hasRunningToolCall(groupedBlocks, runningToolCallIds); + let latestThinkingKey: string | null = null; + for (let blockIndex = groupedBlocks.length - 1; blockIndex >= 0; blockIndex -= 1) { + const block = groupedBlocks[blockIndex]; + if (block?.kind === "thinking") { + latestThinkingKey = block.key; + break; + } + } + + groupedBlocks.forEach((block, blockIndex) => { + processBlocks.push({ + key: `round:${round.key}:block:${block.key}`, + block, + roundMeta: round.meta, + runningToolCallIds, + thinkingOpen: "thinkingOpen" in round ? round.thinkingOpen : false, + isLatestThinking: block.kind === "thinking" && block.key === latestThinkingKey, + isRoundTail: + blockIndex === groupedBlocks.length - 1 && + (answerBlocksByRound.get(round)?.length ?? 0) === 0, + hasRunningToolCall: roundHasRunningToolCall, + }); + }); + } - rounds.forEach((round, roundIndex) => { - const groupedBlocks = groupRoundBlocks(round.blocks).filter((block) => - isVisibleGroupedBlock(block, latestTodoItem), + if (processBlocks.length > 0) { + const expandedMeasurement = processBlocks.reduce( + (total, entry) => { + const measurement = measureBlockUnit( + entry.block, + Boolean(entry.isRoundTail && entry.roundMeta?.usage), + ); + return { + estimate: total.estimate + measurement.estimate + ASSISTANT_UNIT_GAP_PX, + renderCost: total.renderCost + measurement.renderCost, + }; + }, + { estimate: 44, renderCost: 1 }, ); + const defaultOpen = + forceProcessDetailsOpen || + getProcessDetailsDefaultOpen({ + hasSubstantiveAnswer: !live && partition.hasSubstantiveAnswer, + expandByDefault: processDetailsExpanded, + }); + rows.push({ + kind: "assistant-unit", + key: `${replyKey}:process-details`, + replyKey, + estimate: defaultOpen ? expandedMeasurement.estimate : 44, + renderCost: defaultOpen ? Math.min(24, expandedMeasurement.renderCost) : 1, + gapAfter: ASSISTANT_UNIT_GAP_PX, + anchorUserKey, + live, + mutable: false, + renderMode, + compacted, + showAvatar: true, + isAborted, + unit: { + kind: "process", + blocks: processBlocks, + hasRunningToolCall: processBlocks.some((entry) => entry.hasRunningToolCall), + hasSubstantiveAnswer: partition.hasSubstantiveAnswer, + forceOpen: forceProcessDetailsOpen, + }, + }); + } + + for (const { round, groupedBlocks } of answerParts) { const runningToolCallIds = "runningToolCallIds" in round ? round.runningToolCallIds : []; const roundHasRunningToolCall = hasRunningToolCall(groupedBlocks, runningToolCallIds); let latestThinkingKey: string | null = null; @@ -340,26 +479,7 @@ 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, - mutable: true, - renderMode, - compacted, - showAvatar: rows.length === 0, - isAborted, - unit: { kind: "placeholder", showFallbackStatus: false }, - }); - } - }); + } if (live && rows.length === 0) { rows.push({ @@ -445,7 +565,11 @@ export type TranscriptRowModelOptions = { }; export type TranscriptRowModel = { - build: (historyItems: RenderTimelineItem[], live: LiveTailInput) => TranscriptRowsSnapshot; + build: ( + historyItems: RenderTimelineItem[], + live: LiveTailInput, + processDetailsExpanded?: boolean, + ) => TranscriptRowsSnapshot; reset: () => void; }; @@ -455,10 +579,15 @@ export function createTranscriptRowModel(options?: TranscriptRowModelOptions): T { anchorUserKey: string | null; retryTarget: RenderUserMessage | null; + processDetailsExpanded: boolean; rows: TranscriptRow[]; } >(); - let historyRowsCache: { items: RenderTimelineItem[]; rows: TranscriptRow[] } | null = null; + let historyRowsCache: { + items: RenderTimelineItem[]; + processDetailsExpanded: boolean; + rows: TranscriptRow[]; + } | null = null; let streamOrigins = new Map(); let knownKeys = new Set(); let hasBuilt = false; @@ -520,10 +649,16 @@ export function createTranscriptRowModel(options?: TranscriptRowModelOptions): T const buildHistoryRows = ( item: RenderTimelineItem, retryTarget: RenderUserMessage | null, + processDetailsExpanded: boolean, ): TranscriptRow[] => { const anchorUserKey = item.kind === "user" ? item.key : (retryTarget?.key ?? null); const cached = rowCache.get(item); - if (cached && cached.anchorUserKey === anchorUserKey && cached.retryTarget === retryTarget) { + if ( + cached && + cached.anchorUserKey === anchorUserKey && + cached.retryTarget === retryTarget && + cached.processDetailsExpanded === processDetailsExpanded + ) { return cached.rows; } @@ -564,15 +699,17 @@ export function createTranscriptRowModel(options?: TranscriptRowModelOptions): T replyText: buildReplyText(item.rounds), retryTarget, anchorUserKey, + processDetailsExpanded, }); } - rowCache.set(item, { anchorUserKey, retryTarget, rows }); + rowCache.set(item, { anchorUserKey, retryTarget, processDetailsExpanded, rows }); return rows; }; const build = ( historyItems: RenderTimelineItem[], live: LiveTailInput, + processDetailsExpanded = false, ): TranscriptRowsSnapshot => { const liveTailVisible = live.isSending && !live.isSettled; const isInitialBuild = !hasBuilt; @@ -606,18 +743,21 @@ export function createTranscriptRowModel(options?: TranscriptRowModelOptions): T }; let historyRows: TranscriptRow[]; - if (historyRowsCache?.items === historyItems) { + if ( + historyRowsCache?.items === historyItems && + historyRowsCache.processDetailsExpanded === processDetailsExpanded + ) { historyRows = historyRowsCache.rows; } else { historyRows = []; let retryTarget: RenderUserMessage | null = null; for (const item of historyItems) { - const itemRows = buildHistoryRows(item, retryTarget); + const itemRows = buildHistoryRows(item, retryTarget, processDetailsExpanded); historyRows.push(...itemRows); for (const row of itemRows) trackBirth(row.key); if (item.kind === "user") retryTarget = item; } - historyRowsCache = { items: historyItems, rows: historyRows }; + historyRowsCache = { items: historyItems, processDetailsExpanded, rows: historyRows }; } let rows = historyRows; @@ -638,6 +778,7 @@ export function createTranscriptRowModel(options?: TranscriptRowModelOptions): T replyText: "", retryTarget: null, anchorUserKey: historyRows.at(-1)?.anchorUserKey ?? null, + processDetailsExpanded, liveUnitCache: activeTurn.liveUnitCache, }); rows = [...historyRows, ...liveRows]; diff --git a/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts b/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts index 5640481cd..3ec0e4a56 100644 --- a/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts +++ b/crates/agent-gui/src/pages/chat/transcript/transcriptTypes.ts @@ -30,6 +30,7 @@ export type ChatTranscriptProps = { isCompactionRunning: boolean; bottomReservePx?: number; contentWidth: number; + processDetailsExpanded: boolean; onContentWidthChange: (width: number) => void; onOpenFileLink?: (link: ChatFileLink) => void; onResendFromEdit: ( diff --git a/crates/agent-gui/src/pages/settings/SystemSettingsForm.tsx b/crates/agent-gui/src/pages/settings/SystemSettingsForm.tsx index 8088af59d..78bb2dd84 100644 --- a/crates/agent-gui/src/pages/settings/SystemSettingsForm.tsx +++ b/crates/agent-gui/src/pages/settings/SystemSettingsForm.tsx @@ -367,6 +367,34 @@ export function SystemSettingsForm(props: SettingsSectionProps) {
+
+
+
+
+ {t("settings.processDetailsExpanded")} +
+

+ {t("settings.processDetailsExpandedDesc")} +

+
+ + setSettings((prev) => + updateCustomSettings(prev, { + chatTranscript: { + ...prev.customSettings.chatTranscript, + processDetailsExpanded: + !prev.customSettings.chatTranscript.processDetailsExpanded, + }, + }), + ) + } + /> +
+
+
diff --git a/crates/agent-gui/test/chat/process-details-disclosure.test.mjs b/crates/agent-gui/test/chat/process-details-disclosure.test.mjs new file mode 100644 index 000000000..cf8fab202 --- /dev/null +++ b/crates/agent-gui/test/chat/process-details-disclosure.test.mjs @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +let currentOpen; +let stateInitialized; +let disclosureKey; +let disclosureKeySequence = 0; + +function resetHooks({ preserveDisclosureKey = false } = {}) { + currentOpen = undefined; + stateInitialized = false; + if (!preserveDisclosureKey) { + disclosureKey = `conversation:reply-${++disclosureKeySequence}`; + } +} + +const loader = createTsModuleLoader({ + mocks: { + react: { + memo: (component) => component, + useId: () => "process-details-region", + useState(initial) { + if (!stateInitialized) { + currentOpen = typeof initial === "function" ? initial() : initial; + stateInitialized = true; + } + return [ + currentOpen, + (next) => { + currentOpen = typeof next === "function" ? next(currentOpen) : next; + }, + ]; + }, + }, + "../../../../components/icons": { + ChevronRight: (props) => ({ type: "ChevronRight", props }), + Lightbulb: (props) => ({ type: "Lightbulb", props }), + }, + "../../../../i18n": { + useLocale: () => ({ t: (key) => key }), + }, + "./LazyCollapse": { + LazyCollapse: (props) => ({ type: "LazyCollapse", props }), + }, + }, +}); + +const { ProcessDetailsDisclosure } = loader.loadModule( + "src/pages/chat/components/assistant-bubble/ProcessDetailsDisclosure.tsx", +); + +function render(overrides = {}) { + return ProcessDetailsDisclosure({ + disclosureKey, + hasSubstantiveAnswer: true, + expandByDefault: false, + children: () => "body", + ...overrides, + }); +} + +function toggleButton(rendered) { + return rendered.props.children[0]; +} + +test("the aggregate disclosure exposes an accessible collapsed default", () => { + resetHooks(); + const rendered = render(); + const button = toggleButton(rendered); + + assert.equal(button.type, "button"); + assert.equal(button.props.id, "process-details-region-toggle"); + assert.equal(button.props["aria-expanded"], false); + assert.equal(button.props["aria-controls"], "process-details-region"); + const region = rendered.props.children[1]; + assert.equal(region.type, "section"); + assert.equal(region.props["aria-labelledby"], "process-details-region-toggle"); +}); + +test("thinking and activity inside process details start collapsed", () => { + const source = fs.readFileSync( + fileURLToPath( + new URL( + "../../src/pages/chat/components/assistant-bubble/RoundContent.tsx", + import.meta.url, + ), + ), + "utf8", + ); + + assert.match(source, /open=\{withinProcessDetails \? false : isRunning\}/); + assert.equal((source.match(/defaultOpen=\{false\}/g) ?? []).length, 2); +}); + +test("process-only replies open automatically until the user intervenes", () => { + resetHooks(); + const automaticallyOpen = render({ hasSubstantiveAnswer: false }); + assert.equal(toggleButton(automaticallyOpen).props["aria-expanded"], true); + + toggleButton(automaticallyOpen).props.onClick(); + const manuallyClosed = render({ hasSubstantiveAnswer: false }); + assert.equal(toggleButton(manuallyClosed).props["aria-expanded"], false); + + const finalAnswerArrived = render({ hasSubstantiveAnswer: true, expandByDefault: true }); + assert.equal( + toggleButton(finalAnswerArrived).props["aria-expanded"], + false, + "a later automatic default must not override the user's choice for this response", + ); +}); + +test("the setting updates mounted replies that have no manual override", () => { + resetHooks(); + const collapsed = render(); + assert.equal(toggleButton(collapsed).props["aria-expanded"], false); + const expanded = render({ expandByDefault: true }); + assert.equal(toggleButton(expanded).props["aria-expanded"], true); +}); + +test("an untouched process waits for the stream to settle before collapsing", () => { + resetHooks(); + const processOnly = render({ hasSubstantiveAnswer: false, isStreaming: true }); + assert.equal(toggleButton(processOnly).props["aria-expanded"], true); + + const candidateAnswer = render({ hasSubstantiveAnswer: true, isStreaming: true }); + assert.equal(toggleButton(candidateAnswer).props["aria-expanded"], true); + + const laterProcessEvent = render({ hasSubstantiveAnswer: false, isStreaming: true }); + assert.equal(toggleButton(laterProcessEvent).props["aria-expanded"], true); + + const settledAnswer = render({ hasSubstantiveAnswer: true, isStreaming: false }); + assert.equal(toggleButton(settledAnswer).props["aria-expanded"], false); +}); + +test("manual state survives a virtualized unmount and remount", () => { + resetHooks(); + const expanded = render({ expandByDefault: true }); + toggleButton(expanded).props.onClick(); + + resetHooks({ preserveDisclosureKey: true }); + const remounted = render({ expandByDefault: true }); + assert.equal(toggleButton(remounted).props["aria-expanded"], false); + + const settingChanged = render({ expandByDefault: true, forceOpen: true }); + assert.equal( + toggleButton(settingChanged).props["aria-expanded"], + false, + "automatic failure/cancellation visibility must not override a restored manual choice", + ); +}); + +test("failure and cancellation force untouched process details open", () => { + resetHooks(); + const forcedOpen = render({ forceOpen: true }); + assert.equal(toggleButton(forcedOpen).props["aria-expanded"], true); +}); diff --git a/crates/agent-gui/test/chat/process-details-model.test.mjs b/crates/agent-gui/test/chat/process-details-model.test.mjs new file mode 100644 index 000000000..9905a2ea4 --- /dev/null +++ b/crates/agent-gui/test/chat/process-details-model.test.mjs @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const model = loader.loadModule("src/lib/chat/processDetailsModel.ts"); + +function round(key, blocks, meta) { + return { key, blocks, ...(meta ? { meta } : {}) }; +} + +function kinds(partitionedRounds) { + return partitionedRounds.flatMap((entry) => entry.blocks.map((block) => block.kind)); +} + +test("plain assistant text stays outside process details", () => { + const sourceRound = round("r1", [{ kind: "text", id: "text-1", text: "Final answer" }]); + const partition = model.partitionAssistantResponse([sourceRound]); + + assert.equal(partition.hasProcessDetails, false); + assert.equal(partition.hasSubstantiveAnswer, true); + assert.deepEqual(partition.processRounds, []); + assert.equal(partition.answerRounds[0].round, sourceRound); + assert.equal(partition.answerRounds[0].blocks[0], sourceRound.blocks[0]); +}); + +test("thinking, intermediate narration, tools, and hosted search form one cross-round process", () => { + const rounds = [ + round("r1", [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { kind: "text", id: "text-1", text: "I will inspect this." }, + ]), + round("r2", [ + { kind: "tool", item: { toolCall: { id: "call-1" } } }, + { kind: "hostedSearch", item: { id: "search-1" } }, + { kind: "text", id: "text-2", text: "Here is the result." }, + ]), + ]; + + const partition = model.partitionAssistantResponse(rounds); + + assert.equal(partition.hasProcessDetails, true); + assert.equal(partition.hasSubstantiveAnswer, true); + assert.deepEqual(kinds(partition.processRounds), ["thinking", "text", "tool", "hostedSearch"]); + assert.deepEqual(kinds(partition.answerRounds), ["text"]); + assert.equal(partition.answerRounds[0].blocks[0].id, "text-2"); +}); + +test("a later process event reclassifies earlier narration and removes the final-answer boundary", () => { + const partition = model.partitionAssistantResponse([ + round("r1", [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { kind: "text", id: "text-1", text: "Possible answer" }, + { kind: "tool", item: { toolCall: { id: "call-2" } } }, + ]), + ]); + + assert.deepEqual(kinds(partition.processRounds), ["thinking", "text", "tool"]); + assert.deepEqual(partition.answerRounds, []); + assert.equal(partition.hasSubstantiveAnswer, false); +}); + +test("streaming partition preserves provisional text when a later process event arrives", () => { + const partition = model.partitionAssistantResponse( + [ + round("r1", [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { kind: "text", id: "text-1", text: "Possible answer" }, + { kind: "tool", item: { toolCall: { id: "call-2" } } }, + ]), + ], + { preserveStreamingText: true }, + ); + + assert.deepEqual(kinds(partition.processRounds), ["thinking", "tool"]); + assert.deepEqual(kinds(partition.answerRounds), ["text"]); + assert.equal(partition.hasSubstantiveAnswer, true); +}); + +test("process-only, cancelled, or whitespace-only replies remain open", () => { + const partition = model.partitionAssistantResponse([ + round("r1", [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { kind: "text", id: "text-1", text: " " }, + ]), + ]); + + assert.equal(partition.hasSubstantiveAnswer, false); + assert.equal( + model.getProcessDetailsDefaultOpen({ + hasSubstantiveAnswer: partition.hasSubstantiveAnswer, + expandByDefault: false, + }), + true, + ); +}); + +test("completed replies follow the local expand-by-default preference", () => { + assert.equal( + model.getProcessDetailsDefaultOpen({ + hasSubstantiveAnswer: true, + expandByDefault: false, + }), + false, + ); + assert.equal( + model.getProcessDetailsDefaultOpen({ + hasSubstantiveAnswer: true, + expandByDefault: true, + }), + true, + ); +}); + +test("terminal failures, unresolved tool errors, and timeouts request automatic visibility", () => { + const completedBlocks = [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { kind: "text", id: "text-1", text: "Final answer" }, + ]; + + for (const stopReason of ["aborted", "error"]) { + assert.equal( + model.shouldForceProcessDetailsOpen([round("r1", completedBlocks, { stopReason })]), + true, + ); + } + assert.equal( + model.shouldForceProcessDetailsOpen([ + round("r1", [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { + kind: "tool", + item: { + toolCall: { id: "call-1" }, + toolResult: { isError: true, details: {} }, + }, + }, + ]), + ]), + true, + ); + assert.equal( + model.shouldForceProcessDetailsOpen([ + round("r1", [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { + kind: "tool", + item: { + toolCall: { id: "question-1" }, + toolResult: { + isError: false, + details: { kind: "ask_user_question", timedOut: true }, + }, + }, + }, + { kind: "text", id: "text-1", text: "Final answer" }, + ]), + ]), + true, + ); + assert.equal(model.shouldForceProcessDetailsOpen([round("r1", completedBlocks)]), false); +}); + +test("a recovered tool error does not override the final-answer disclosure preference", () => { + assert.equal( + model.shouldForceProcessDetailsOpen([ + round("r1", [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { + kind: "tool", + item: { + toolCall: { id: "call-1" }, + toolResult: { isError: true, details: {} }, + }, + }, + { kind: "text", id: "text-1", text: "Recovered final answer" }, + ]), + ]), + false, + ); +}); 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..c24426ba0 100644 --- a/crates/agent-gui/test/chat/transcript-row-model.test.mjs +++ b/crates/agent-gui/test/chat/transcript-row-model.test.mjs @@ -57,6 +57,12 @@ function blockRows(snapshot) { ); } +function processRows(snapshot) { + return snapshot.rows.filter( + (row) => row.kind === "assistant-unit" && row.unit.kind === "process", + ); +} + function footerRows(snapshot) { return snapshot.rows.filter( (row) => row.kind === "assistant-unit" && row.unit.kind === "footer", @@ -102,6 +108,45 @@ test("settling a live turn preserves every block-unit key and adds one footer un assert.equal(blockRows(rebuilt)[0].key, liveBlockKey); }); +test("the aggregate process key survives the live-to-history handoff", () => { + const model = createTranscriptRowModel(); + const history = [userItem("u1")]; + const thinkingBlock = { kind: "thinking", id: "thinking-1", text: "working" }; + const liveRound = { + round: 1, + key: "r1", + blocks: [thinkingBlock], + runningToolCallIds: [], + thinkingOpen: true, + }; + + const streaming = model.build(history, { + ...idleLive, + isSending: true, + liveRounds: [liveRound], + }); + const liveProcessKey = processRows(streaming)[0].key; + assert.match(liveProcessKey, /^live-turn-\d+:process-details$/); + + const settled = model.build( + [ + ...history, + assistantItem("a1", [ + { + round: 1, + key: "r1", + blocks: [thinkingBlock, { kind: "text", id: "text-1", text: "Final answer" }], + }, + ]), + ], + idleLive, + ); + + assert.equal(processRows(settled)[0].key, liveProcessKey); + assert.equal(processRows(settled)[0].renderMode, "streaming"); + assert.equal(blockRows(settled)[0].unit.block.text, "Final answer"); +}); + test("persist lag: block-unit aliases still land one build later", () => { const model = createTranscriptRowModel(); const history = [userItem("u1")]; @@ -270,7 +315,7 @@ test("terminal settlement removes the live tail before sending clears", () => { assert.equal(nextPending.rows[3].mutable, true); }); -test("assistant rounds flatten into grouped top-level render units", () => { +test("assistant process activity becomes one cross-round top-level render unit", () => { const model = createTranscriptRowModel(); const tool = (id, name = "Read") => ({ kind: "tool", @@ -290,13 +335,15 @@ test("assistant rounds flatten into grouped top-level render units", () => { }, ]; const snapshot = model.build([userItem("u1"), assistantItem("a1", rounds)], idleLive); + assert.equal(processRows(snapshot).length, 1); assert.deepEqual( - blockRows(snapshot).map((row) => row.unit.block.kind), + processRows(snapshot)[0].unit.blocks.map((entry) => entry.block.kind), ["text", "thinking", "toolGroup", "hostedSearchGroup"], ); + assert.equal(blockRows(snapshot).length, 0); assert.equal(footerRows(snapshot).length, 1); - assert.equal(blockRows(snapshot)[0].showAvatar, true); - assert.ok(blockRows(snapshot).slice(1).every((row) => !row.showAvatar)); + assert.equal(processRows(snapshot)[0].showAvatar, true); + assert.match(processRows(snapshot)[0].key, /:process-details$/); }); test("Markdown text blocks stay whole instead of being string-sliced", () => { @@ -311,7 +358,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("only the final-answer live tail is pinned while the process unit virtualizes", () => { const model = createTranscriptRowModel(); const liveRound = { round: 1, @@ -329,16 +376,70 @@ test("only the mutable live tail is pinned while completed prefix units virtuali isSending: true, liveRounds: [liveRound], }); - const units = blockRows(snapshot); - assert.equal(units.length, 3); + const process = processRows(snapshot)[0]; + const answers = blockRows(snapshot); + const answer = answers.at(-1); + assert.equal(process.unit.blocks.length, 1); + assert.deepEqual( + process.unit.blocks.map((entry) => entry.block.kind), + ["thinking"], + ); + assert.equal(process.mutable, false); assert.deepEqual( - units.map((row) => row.mutable), - [false, false, true], + answers.map((row) => row.unit.block.text), + ["prefix", "streaming tail"], ); - assert.equal(snapshot.liveStartIndex, snapshot.rows.indexOf(units[2])); + assert.equal(answer.unit.block.kind, "text"); + assert.equal(answer.unit.block.text, "streaming tail"); + assert.equal(answer.mutable, true); + assert.equal(snapshot.liveStartIndex, snapshot.rows.indexOf(answer)); assert.equal(snapshot.liveStartIndex, snapshot.rows.length - 1); }); +test("later live process events preserve the provisional answer row identity", () => { + const births = []; + const model = createTranscriptRowModel({ + onRowsBorn: (keys, isInitialBuild) => births.push([keys.slice(), isInitialBuild]), + }); + const thinkingBlock = { kind: "thinking", id: "thinking-1", text: "working" }; + const textBlock = { kind: "text", id: "text-1", text: "provisional answer" }; + const toolBlock = { + kind: "tool", + item: { toolCall: { type: "toolCall", id: "call-1", name: "Read", arguments: {} } }, + }; + const buildLive = (blocks) => + model.build([userItem("u1")], { + ...idleLive, + isSending: true, + liveRounds: [ + { + round: 1, + key: "r1", + blocks, + runningToolCallIds: [], + thinkingOpen: false, + }, + ], + }); + + const provisional = buildLive([thinkingBlock, textBlock]); + const withLaterTool = buildLive([thinkingBlock, textBlock, toolBlock]); + const provisionalAnswer = blockRows(provisional)[0]; + const stableAnswer = blockRows(withLaterTool)[0]; + + assert.deepEqual( + withLaterTool.rows.map((row) => row.key), + provisional.rows.map((row) => row.key), + ); + assert.equal(stableAnswer.key, provisionalAnswer.key); + assert.equal(withLaterTool.liveStartIndex, provisional.liveStartIndex); + assert.equal(births.length, 1); + assert.deepEqual( + processRows(withLaterTool)[0].unit.blocks.map((entry) => entry.block.kind), + ["thinking", "tool"], + ); +}); + test("assistant unit keys do not depend on the history-window-relative index", () => { const model = createTranscriptRowModel(); const assistant = assistantItem("assistant-stable", [round("r1", "reply")]); @@ -364,7 +465,7 @@ test("assistant unit keys do not depend on the history-window-relative index", ( ); }); -test("usage stays on each round tail and changed files stay on the reply footer", () => { +test("usage metadata stays with process and answer tails while changed files stay on the footer", () => { const model = createTranscriptRowModel(); const usage = { input: 10, @@ -396,18 +497,156 @@ test("usage stays on each round tail and changed files stay on the reply footer" meta: { usage }, }, { round: 2, key: "r2", blocks: [writeTool] }, + { round: 3, key: "r3", blocks: [{ kind: "text", id: "text-1", text: "final" }] }, ]; const snapshot = model.build([userItem("u1"), assistantItem("a1", rounds)], idleLive); - const units = blockRows(snapshot); + const process = processRows(snapshot)[0]; + const answer = blockRows(snapshot)[0]; assert.deepEqual( - units.map((row) => row.unit.isRoundTail), + process.unit.blocks.map((entry) => entry.isRoundTail), [false, true, true], ); - assert.equal(units[1].unit.roundMeta.usage, usage); + assert.equal(process.unit.blocks[1].roundMeta.usage, usage); + assert.equal(answer.unit.isRoundTail, true); const footer = footerRows(snapshot)[0]; assert.equal(footer.unit.hasChangedFilesCandidate, true); assert.equal(collectChangedFiles(footer.unit.rounds).files[0].path, "src/result.ts"); - assert.equal(footer.unit.replyText, "firstsecond"); + assert.equal(footer.unit.replyText, "final"); +}); + +test("process estimates follow the local default while no-answer processes stay expanded", () => { + const model = createTranscriptRowModel(); + const processThenAnswer = assistantItem("a1", [ + { + round: 1, + key: "r1", + blocks: [ + { kind: "thinking", id: "thinking-1", text: "long thought ".repeat(100) }, + { kind: "text", id: "text-1", text: "final answer" }, + ], + }, + ]); + + const collapsed = model.build([userItem("u1"), processThenAnswer], idleLive, false); + const expanded = model.build([userItem("u1"), processThenAnswer], idleLive, true); + assert.equal(processRows(collapsed)[0].estimate, 44); + assert.ok(processRows(expanded)[0].estimate > processRows(collapsed)[0].estimate); + + const noAnswer = model.build( + [ + userItem("u1"), + assistantItem("a2", [ + { + round: 1, + key: "r1", + blocks: [{ kind: "thinking", id: "thinking-1", text: "unfinished" }], + }, + ]), + ], + idleLive, + false, + ); + assert.ok(processRows(noAnswer)[0].estimate > 44); +}); + +test("active process rows stay expanded until answers settle while failures stay expanded", () => { + const completedBlocks = [ + { kind: "thinking", id: "thinking-1", text: "long thought ".repeat(100) }, + { kind: "text", id: "text-1", text: "final answer" }, + ]; + const normal = createTranscriptRowModel().build( + [userItem("u1"), assistantItem("normal", [{ round: 1, key: "r1", blocks: completedBlocks }])], + idleLive, + false, + ); + assert.equal(processRows(normal)[0].estimate, 44); + assert.equal(processRows(normal)[0].unit.forceOpen, false); + + const active = createTranscriptRowModel().build( + [userItem("u1")], + { + ...idleLive, + isSending: true, + liveRounds: [ + { + round: 1, + key: "r1", + blocks: completedBlocks, + runningToolCallIds: [], + thinkingOpen: false, + }, + ], + }, + false, + ); + assert.ok(processRows(active)[0].estimate > 44); + assert.equal(processRows(active)[0].unit.forceOpen, false); + + const activeWithoutAnswer = createTranscriptRowModel().build( + [userItem("u1")], + { + ...idleLive, + isSending: true, + liveRounds: [ + { + round: 1, + key: "r1", + blocks: [{ kind: "thinking", id: "thinking-1", text: "still working" }], + runningToolCallIds: [], + thinkingOpen: true, + }, + ], + }, + false, + ); + assert.ok(processRows(activeWithoutAnswer)[0].estimate > 44); + assert.equal(processRows(activeWithoutAnswer)[0].unit.forceOpen, false); + + const failed = createTranscriptRowModel().build( + [ + userItem("u1"), + assistantItem("failed", [ + { round: 1, key: "r1", blocks: completedBlocks, meta: { stopReason: "error" } }, + ]), + ], + idleLive, + false, + ); + assert.ok(processRows(failed)[0].estimate > 44); + assert.equal(processRows(failed)[0].unit.forceOpen, true); + + const timedOut = createTranscriptRowModel().build( + [ + userItem("u1"), + assistantItem("timed-out", [ + { + round: 1, + key: "r1", + blocks: [ + { kind: "thinking", id: "thinking-1", text: "Plan" }, + { + kind: "tool", + item: { + toolCall: { type: "toolCall", id: "question-1", name: "AskUserQuestion" }, + toolResult: { + role: "toolResult", + toolCallId: "question-1", + isError: false, + content: [], + details: { kind: "ask_user_question", timedOut: true }, + }, + }, + }, + { kind: "text", id: "text-1", text: "final answer" }, + ], + }, + ]), + ], + idleLive, + false, + ); + assert.ok(processRows(timedOut)[0].estimate > 44); + assert.equal(processRows(timedOut)[0].unit.forceOpen, true); }); test("cost-aware overscan spends one giant unit instead of five fixed rows", () => { @@ -445,3 +684,23 @@ test("transcript virtualizer keeps scroll updates off the full React measurement assert.doesNotMatch(transcriptListSource, /height:\s*virtualizer\.getTotalSize\(\)/); assert.doesNotMatch(transcriptListSource, /transform:\s*`translateY\(/); }); + +test("process detail preference invalidates desktop transcript measurements", () => { + assert.match( + transcriptListSource, + /process-details-\$\{\s*processDetailsExpanded \? "expanded" : "collapsed"\s*\}/, + ); + assert.equal( + (transcriptListSource.match(/buildVersionedTranscriptLayoutKey\(/g) ?? []).length, + 3, + "the preference-aware layout key must gate both restore and save", + ); + assert.match( + transcriptListSource, + /const previousProcessDetailsExpandedRef = useRef\(processDetailsExpanded\);/, + ); + assert.match( + transcriptListSource, + /previousProcessDetailsExpandedRef\.current = processDetailsExpanded;[\s\S]*?virtualizer\.measure\(\);/, + ); +}); diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index 6396fc646..1d6297c08 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -1441,8 +1441,12 @@ test("gateway settings sync keeps right dock width local and syncs project state const payload = sync.buildGatewaySettingsSyncPayload(incoming); const synced = sync.applyGatewaySettingsSyncPayload(current, payload); - assert.equal(payload.customSettings.chatTranscript.width, 768); + assert.deepEqual(payload.customSettings.chatTranscript, { + width: 768, + processDetailsExpanded: false, + }); assert.equal(synced.customSettings.chatTranscript.width, 920); + assert.equal(synced.customSettings.chatTranscript.processDetailsExpanded, false); assert.equal(synced.customSettings.rightDock.width, 612); assert.deepEqual(Object.keys(synced.customSettings.rightDock.projects).sort(), [ "/desktop/project", @@ -2179,18 +2183,66 @@ test("font scale settings normalize invalid values to 1 and clamp out-of-range v assert.deepEqual(custom.fontScale, { sidebar: 1, chat: 1.2, rightDock: 1 }); }); -test("chat transcript width defaults, clamps, and updates locally", () => { - assert.deepEqual(settings.normalizeChatTranscriptSettings(undefined), { width: 768 }); - assert.deepEqual(settings.normalizeChatTranscriptSettings({ width: 400 }), { width: 560 }); - assert.deepEqual(settings.normalizeChatTranscriptSettings({ width: 1400 }), { width: 1200 }); - assert.deepEqual(settings.normalizeChatTranscriptSettings({ width: 920.4 }), { width: 920 }); +test("chat transcript presentation defaults, clamps, and updates locally", () => { + assert.deepEqual(settings.normalizeChatTranscriptSettings(undefined), { + width: 768, + processDetailsExpanded: false, + }); + assert.deepEqual(settings.normalizeChatTranscriptSettings({ width: 400 }), { + width: 560, + processDetailsExpanded: false, + }); + assert.deepEqual(settings.normalizeChatTranscriptSettings({ width: 1400 }), { + width: 1200, + processDetailsExpanded: false, + }); + assert.deepEqual( + settings.normalizeChatTranscriptSettings({ + width: 920.4, + processDetailsExpanded: true, + }), + { width: 920, processDetailsExpanded: true }, + ); + assert.equal( + settings.normalizeChatTranscriptSettings({ processDetailsExpanded: "true" }) + .processDetailsExpanded, + false, + ); - const current = settings.normalizeSettings({ customSettings: { chatTranscript: { width: 768 } } }); + const current = settings.normalizeSettings({ + customSettings: { chatTranscript: { width: 768, processDetailsExpanded: true } }, + }); const updated = settings.updateChatTranscriptWidth(current, 960); assert.equal(updated.customSettings.chatTranscript.width, 960); + assert.equal(updated.customSettings.chatTranscript.processDetailsExpanded, true); assert.equal(settings.updateChatTranscriptWidth(updated, 960), updated); }); +test("gateway settings sync keeps process detail expansion local", () => { + const current = settings.normalizeSettings({ + customSettings: { + chatTranscript: { width: 920, processDetailsExpanded: true }, + }, + }); + const incoming = settings.normalizeSettings({ + customSettings: { + chatTranscript: { width: 1100, processDetailsExpanded: false }, + }, + }); + + const payload = sync.buildGatewaySettingsSyncPayload(incoming); + const synced = sync.applyGatewaySettingsSyncPayload(current, payload); + + assert.deepEqual(payload.customSettings.chatTranscript, { + width: 768, + processDetailsExpanded: false, + }); + assert.deepEqual(synced.customSettings.chatTranscript, { + width: 920, + processDetailsExpanded: true, + }); +}); + test("close window behavior defaults to minimize and only accepts exit", () => { assert.equal(settings.normalizeCloseWindowBehavior(undefined), "minimize"); assert.equal(settings.normalizeCloseWindowBehavior("tray"), "minimize"); diff --git a/scripts/mirror-manifest.json b/scripts/mirror-manifest.json index 30bd47234..327cb74cd 100644 --- a/scripts/mirror-manifest.json +++ b/scripts/mirror-manifest.json @@ -39,6 +39,8 @@ "components/hub/ToolPolicyToggle.tsx", "lib/chat/askUserQuestion.ts", "lib/chat/openChatFileLink.ts", + "lib/chat/processDetailsDisclosureState.ts", + "lib/chat/processDetailsModel.ts", "lib/chat/toolApprovalArgs.ts", "components/Markdown.tsx", "lib/markdownCodeBlockPolicy.ts",