diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index c018ac1af..a4bfb841f 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -23,8 +23,8 @@ use bitfun_core::service::session::{DialogTurnData, SessionRelationship}; const SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET: usize = 512 * 1024; const SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT: usize = 16 * 1024; -const SESSION_VIEW_TRUNCATED_MARKER: &str = "\n...[truncated for session view]"; -const SESSION_VIEW_OMITTED_MARKER: &str = "[truncated for session view]"; +const SESSION_VIEW_TRUNCATED_MARKER: &str = "\n... Output truncated for session preview"; +const SESSION_VIEW_OMITTED_MARKER: &str = "Output omitted from session preview"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1863,7 +1863,8 @@ mod tests { .as_str() .expect("output should remain a visible string preview"); assert!(output.len() < 80 * 1024); - assert!(output.contains("truncated for session view")); + assert!(!output.contains("[truncated for session view]")); + assert!(output.contains("Output truncated for session preview")); assert_eq!(tool_result.result["exit_code"], 0); assert_eq!(tool_result.result_for_assistant, None); } diff --git a/src/web-ui/src/flow_chat/components/CopyOutputButton.tsx b/src/web-ui/src/flow_chat/components/CopyOutputButton.tsx index f1b9ee2aa..265e74589 100644 --- a/src/web-ui/src/flow_chat/components/CopyOutputButton.tsx +++ b/src/web-ui/src/flow_chat/components/CopyOutputButton.tsx @@ -11,6 +11,7 @@ import { createMarkdownEditorTab } from '@/shared/utils/tabUtils'; import { Tooltip } from '@/component-library'; import { i18nService } from '@/infrastructure/i18n'; import { createLogger } from '@/shared/utils/logger'; +import { formatSessionViewPreviewText } from '../utils/sessionViewPreview'; import './CopyOutputButton.css'; const log = createLogger('CopyOutputButton'); @@ -65,7 +66,7 @@ export const CopyOutputButton: React.FC = ({ const resultStr = typeof toolItem.toolResult.result === 'string' ? toolItem.toolResult.result : JSON.stringify(toolItem.toolResult.result, null, 2); - toolContent += `\n[Result]\n\`\`\`\n${resultStr}\n\`\`\`\n`; + toolContent += `\n[Result]\n\`\`\`\n${formatSessionViewPreviewText(resultStr)}\n\`\`\`\n`; } } diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss index 09fe3458f..70c9d5132 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss @@ -51,6 +51,13 @@ margin-top: var(--flowchat-inline-gap, 0.35rem); } +.model-round-item__history-loader { + color: var(--color-text-muted, #999); + font-size: 12px; + line-height: 18px; + padding: var(--flowchat-inline-gap, 0.35rem) 0; +} + .model-round-item__action-btn { display: flex; align-items: center; diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index 1fc58d15f..c0fc4dd29 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -21,9 +21,16 @@ import { taskCollapseStateManager } from '../../store/TaskCollapseStateManager'; import { ExportImageButton } from './ExportImageButton'; import { ForkSessionButton } from './ForkSessionButton'; import { buildModelRoundItemGroups, COMPLETED_TOOL_TRANSIENT_MS } from './modelRoundItemGrouping'; +import { + MODEL_ROUND_GROUP_RENDER_CHUNK_DELAY_MS, + getInitialModelRoundGroupRenderCount, + getNextModelRoundGroupRenderCount, + getSynchronizedModelRoundGroupRenderCount, +} from './modelRoundProgressiveRender'; import { Tooltip } from '@/component-library'; import { createLogger } from '@/shared/utils/logger'; import { SubagentProjectionView } from '../subagent/SubagentProjectionView'; +import { formatSessionViewPreviewText } from '../../utils/sessionViewPreview'; import './ModelRoundItem.scss'; import './SubagentItems.scss'; @@ -169,6 +176,71 @@ export const ModelRoundItem = React.memo( }); }, [round.isStreaming, round.renderHints?.disableExploreGrouping, sortedItems, transientNowMs]); + const initialGroupRenderCount = useMemo(() => ( + getInitialModelRoundGroupRenderCount({ + groupCount: groupedItems.length, + isStreaming: round.isStreaming, + }) + ), [groupedItems.length, round.isStreaming]); + + const [renderedGroupState, setRenderedGroupState] = useState(() => ({ + roundId: round.id, + count: initialGroupRenderCount, + })); + + useEffect(() => { + setRenderedGroupState((current) => { + if (current.roundId !== round.id) { + return { roundId: round.id, count: initialGroupRenderCount }; + } + + const nextCount = getSynchronizedModelRoundGroupRenderCount({ + currentCount: current.count, + groupCount: groupedItems.length, + initialCount: initialGroupRenderCount, + isStreaming: round.isStreaming, + }); + + return current.count === nextCount + ? current + : { roundId: round.id, count: nextCount }; + }); + }, [groupedItems.length, initialGroupRenderCount, round.id, round.isStreaming]); + + const renderedGroupCount = renderedGroupState.roundId === round.id + ? renderedGroupState.count + : initialGroupRenderCount; + + useEffect(() => { + if (round.isStreaming || renderedGroupCount >= groupedItems.length) { + return; + } + + const timeoutId = window.setTimeout(() => { + setRenderedGroupState((current) => { + if (current.roundId !== round.id) { + return current; + } + + return { + roundId: round.id, + count: getNextModelRoundGroupRenderCount({ + currentCount: current.count, + groupCount: groupedItems.length, + }), + }; + }); + }, MODEL_ROUND_GROUP_RENDER_CHUNK_DELAY_MS); + + return () => window.clearTimeout(timeoutId); + }, [groupedItems.length, renderedGroupCount, round.id, round.isStreaming]); + + const visibleGroupedItems = useMemo( + () => groupedItems.slice(0, renderedGroupCount), + [groupedItems, renderedGroupCount], + ); + const hasDeferredGroups = renderedGroupCount < groupedItems.length; + const extractDialogTurnContent = useCallback(() => { const flowChatStore = FlowChatStore.getInstance(); const state = flowChatStore.getState(); @@ -218,7 +290,7 @@ export const ModelRoundItem = React.memo( const resultStr = typeof item.toolResult.result === 'string' ? item.toolResult.result : JSON.stringify(item.toolResult.result, null, 2); - toolContent += `\n[Result]\n\`\`\`\n${resultStr}\n\`\`\`\n`; + toolContent += `\n[Result]\n\`\`\`\n${formatSessionViewPreviewText(resultStr)}\n\`\`\`\n`; } } @@ -260,8 +332,8 @@ export const ModelRoundItem = React.memo(
- {groupedItems.map((group, groupIndex) => { - const isLastGroup = groupIndex === groupedItems.length - 1; + {visibleGroupedItems.map((group, groupIndex) => { + const isLastGroup = !hasDeferredGroups && groupIndex === groupedItems.length - 1; const isLast = isLastRound && isLastGroup; switch (group.type) { case 'explore': @@ -304,6 +376,12 @@ export const ModelRoundItem = React.memo( return null; } })} + + {hasDeferredGroups && ( +
+ {t('modelRound.loadingMoreHistory', { defaultValue: 'Loading more history...' })} +
+ )} {isTurnComplete && isLastRound && hasContent && !round.isStreaming && (
diff --git a/src/web-ui/src/flow_chat/components/modern/modelRoundProgressiveRender.test.ts b/src/web-ui/src/flow_chat/components/modern/modelRoundProgressiveRender.test.ts new file mode 100644 index 000000000..638eae25a --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/modelRoundProgressiveRender.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { + MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE, + MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT, + getInitialModelRoundGroupRenderCount, + getNextModelRoundGroupRenderCount, + getSynchronizedModelRoundGroupRenderCount, +} from './modelRoundProgressiveRender'; + +describe('modelRoundProgressiveRender', () => { + it('renders completed large historical rounds in bounded initial chunks', () => { + expect(getInitialModelRoundGroupRenderCount({ + groupCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 25, + isStreaming: false, + })).toBe(MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT); + }); + + it('keeps streaming rounds fully rendered', () => { + expect(getInitialModelRoundGroupRenderCount({ + groupCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 25, + isStreaming: true, + })).toBe(MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 25); + }); + + it('advances chunked historical rendering without overshooting the group count', () => { + expect(getNextModelRoundGroupRenderCount({ + currentCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT, + groupCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE + 5, + })).toBe(MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE); + + expect(getNextModelRoundGroupRenderCount({ + currentCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE, + groupCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE + 5, + })).toBe(MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE + 5); + }); + + it('does not shrink a fully rendered round after streaming completes', () => { + expect(getSynchronizedModelRoundGroupRenderCount({ + currentCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 120, + groupCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 120, + initialCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT, + isStreaming: false, + })).toBe(MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 120); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/modelRoundProgressiveRender.ts b/src/web-ui/src/flow_chat/components/modern/modelRoundProgressiveRender.ts new file mode 100644 index 000000000..0e3d6e42e --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/modelRoundProgressiveRender.ts @@ -0,0 +1,37 @@ +export const MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT = 80; +export const MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE = 80; +export const MODEL_ROUND_GROUP_RENDER_CHUNK_DELAY_MS = 16; + +export function getInitialModelRoundGroupRenderCount(params: { + groupCount: number; + isStreaming: boolean; +}): number { + const { groupCount, isStreaming } = params; + if (isStreaming || groupCount <= MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT) { + return groupCount; + } + + return MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT; +} + +export function getNextModelRoundGroupRenderCount(params: { + currentCount: number; + groupCount: number; +}): number { + const { currentCount, groupCount } = params; + return Math.min(groupCount, currentCount + MODEL_ROUND_GROUP_RENDER_CHUNK_SIZE); +} + +export function getSynchronizedModelRoundGroupRenderCount(params: { + currentCount: number; + groupCount: number; + initialCount: number; + isStreaming: boolean; +}): number { + const { currentCount, groupCount, initialCount, isStreaming } = params; + if (isStreaming) { + return groupCount; + } + + return Math.min(groupCount, Math.max(currentCount, initialCount)); +} diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts index de5ec2b8e..bb1d41fc9 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts @@ -9,6 +9,7 @@ import { getElementText, copyTextToClipboard } from '@/shared/utils/textSelectio import { createLogger } from '@/shared/utils/logger'; import { FlowChatStore } from '../../store/FlowChatStore'; import { i18nService } from '@/infrastructure/i18n'; +import { formatSessionViewPreviewText } from '../../utils/sessionViewPreview'; const log = createLogger('useFlowChatCopyDialog'); @@ -61,7 +62,7 @@ function extractDialogTurnContent(turnId: string): string { const resultStr = typeof item.toolResult.result === 'string' ? item.toolResult.result : JSON.stringify(item.toolResult.result, null, 2); - toolContent += `\n[Result]\n\`\`\`\n${resultStr}\n\`\`\`\n`; + toolContent += `\n[Result]\n\`\`\`\n${formatSessionViewPreviewText(resultStr)}\n\`\`\`\n`; } } diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx index 08d2c6ff3..31e21bf58 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx @@ -213,20 +213,37 @@ vi.mock('react-i18next', () => ({ })); vi.mock('@/component-library', () => ({ - IconButton: ({ + IconButton: React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes & { variant?: string; size?: string } + >(function MockIconButton({ children, variant: _variant, size: _size, ...props - }: React.ButtonHTMLAttributes & { variant?: string; size?: string }) => ( - - ), + }, ref) { + return ( + + ); + }), MarkdownRenderer: ({ content }: { content: string }) =>
{content}
, - Tooltip: ({ children, content }: { children: React.ReactNode; content?: React.ReactNode }) => ( - {children} - ), + Tooltip: ({ children, content }: { children: React.ReactNode; content?: React.ReactNode }) => { + const tooltipContent = typeof content === 'string' ? content : undefined; + let trigger = children; + if (React.isValidElement(children)) { + trigger = React.cloneElement( + children as React.ReactElement<{ + ref?: React.Ref; + }>, + { + ref: () => undefined, + } + ); + } + return {trigger}; + }, ToolProcessingDots: ({ className }: { className?: string }) => ..., })); @@ -659,6 +676,7 @@ describe('Session usage report UI components', () => { it('keeps chat card file names visible and labels model tokens', () => { dom.window.localStorage.setItem(USAGE_EXPORT_REDACT_PATHS_STORAGE_KEY, 'false'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); const longPath = 'src/features/session-usage/reports/components/very/deeply/nested/UsageReportCardFilePathThatWouldNormallyOverflow.tsx'; const fileName = 'UsageReportCardFilePathThatWouldNormallyOverflow.tsx'; const report = usageReport({ @@ -689,12 +707,20 @@ describe('Session usage report UI components', () => { }, }); - render( - - ); + let refWarnings: unknown[][] = []; + try { + render( + + ); + refWarnings = consoleError.mock.calls.filter(([message]) => + String(message).includes('Function components cannot be given refs') + ); + } finally { + consoleError.mockRestore(); + } const fileNameLabel = container.querySelector('.session-usage-report-card__mini-list-file-name'); expect(fileNameLabel?.textContent).toBe(fileName); @@ -702,6 +728,7 @@ describe('Session usage report UI components', () => { expect(container.textContent).not.toContain('/.../'); expect(container.querySelector(`[data-tooltip="${longPath}"]`)).not.toBeNull(); expect(container.textContent).toContain('1,500 tokens'); + expect(refWarnings).toEqual([]); }); it('syncs path redaction between the chat card and detail panel', () => { diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx index c37c582fa..aba3dc0d8 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx @@ -48,6 +48,16 @@ interface SessionUsageReportCardProps { onOpenDetails?: (report: SessionUsageReport, initialTab?: SessionUsagePanelTab) => void; } +const UsageMiniListFilePathLabel = React.forwardRef( + function UsageMiniListFilePathLabel({ pathLabel }, ref) { + return ( + + {getUsageFileNameFromPath(pathLabel)} + + ); + } +); + export const SessionUsageReportCard: React.FC = ({ report, markdown = '', @@ -465,14 +475,6 @@ function UsageMiniListLabelView({ label }: { label: UsageMiniListLabel }) { : node; } -function UsageMiniListFilePathLabel({ pathLabel }: { pathLabel: string }) { - return ( - - {getUsageFileNameFromPath(pathLabel)} - - ); -} - function UsageFileChangeDetail({ addedLines, deletedLines, diff --git a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx index ec13a8b90..72b240331 100644 --- a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx @@ -12,6 +12,10 @@ import { ToolCardStatusSlot } from './ToolCardStatusSlot'; import { useToolCardHeightContract } from './useToolCardHeightContract'; import { hasAcpPermissionOptions } from './AcpPermissionActions.utils'; import { AcpPermissionActions } from './AcpPermissionActions'; +import { + formatSessionViewPreviewText, + isOnlySessionViewPreviewText, +} from '../utils/sessionViewPreview'; import './DefaultToolCard.scss'; const MAX_PREVIEW_CHARS = 4000; @@ -40,12 +44,12 @@ function hasVisibleValue(value: any): boolean { function stringifyValue(value: any): string { try { if (typeof value === 'string') { - return value; + return formatSessionViewPreviewText(value); } - return JSON.stringify(value, null, 2); + return formatSessionViewPreviewText(JSON.stringify(value, null, 2)); } catch { - return String(value); + return formatSessionViewPreviewText(String(value)); } } @@ -60,6 +64,7 @@ function getInlinePreview(value: any): string | null { if (typeof value === 'string') { const normalized = value.replace(/\s+/g, ' ').trim(); if (!normalized) return null; + if (isOnlySessionViewPreviewText(normalized)) return null; return normalized.length > 72 ? `${normalized.slice(0, 72)}...` : normalized; } diff --git a/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx index dc3d04717..7244a6c55 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx @@ -9,6 +9,7 @@ import type { ToolCardProps } from '../types/flow-chat'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; import { ToolCardStatusSlot } from './ToolCardStatusSlot'; import { useToolCardHeightContract } from './useToolCardHeightContract'; +import { formatSessionViewPreviewText } from '../utils/sessionViewPreview'; export const GrepSearchDisplay: React.FC = ({ toolItem, onExpand @@ -119,7 +120,7 @@ export const GrepSearchDisplay: React.FC = ({ maxHeight: '400px', overflow: 'auto' }}> - {toolResult.result.result} + {formatSessionViewPreviewText(String(toolResult.result.result))}
)} diff --git a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.test.tsx index 643a585fd..f7a842f26 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.test.tsx @@ -138,4 +138,49 @@ describe('ReadFileDisplay', () => { }); expect(onReject).toHaveBeenCalledWith('reject'); }); + + it('does not report a file size for session preview truncation markers', () => { + const toolItem: FlowToolItem = { + id: 'tool-read-2', + type: 'tool', + toolName: 'Read', + status: 'completed', + timestamp: Date.now(), + toolCall: { + id: 'call-read-2', + input: { + file_path: 'src/main.rs', + }, + }, + toolResult: { + id: 'result-read-2', + result: { + content: '[truncated for session view]', + }, + timestamp: Date.now(), + }, + }; + + const config: ToolCardConfig = { + toolName: 'Read', + displayName: 'Read File', + icon: 'R', + requiresConfirmation: false, + resultDisplayType: 'summary', + description: 'Read file contents', + displayMode: 'compact', + }; + + act(() => { + root.render( + + ); + }); + + expect(container.textContent).toContain('main.rs'); + expect(container.textContent).not.toMatch(/\(\d+B\)/); + }); }); diff --git a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx index d1ee33108..df1002527 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx @@ -12,6 +12,7 @@ import { hasAcpPermissionOptions } from './AcpPermissionActions.utils'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; import { ToolCardHeaderActions } from './ToolCardHeaderActions'; import { ToolCardStatusSlot } from './ToolCardStatusSlot'; +import { isSessionViewPreviewText } from '../utils/sessionViewPreview'; export const ReadFileDisplay: React.FC = React.memo(({ toolItem, @@ -93,6 +94,7 @@ export const ReadFileDisplay: React.FC = React.memo(({ const content = toolResult.result.content || toolResult.result; if (typeof content === 'string') { + if (isSessionViewPreviewText(content)) return null; const bytes = new TextEncoder().encode(content).length; if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx index 18a9e90f1..babc17cac 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -31,6 +31,7 @@ import { ToolCardCopyAction, ToolCardHeaderActions } from './ToolCardHeaderActio import { ToolCommandPreview } from './ToolCommandPreview'; import { hasAcpPermissionOptions } from './AcpPermissionActions.utils'; import { AcpPermissionActions } from './AcpPermissionActions'; +import { formatSessionViewPreviewText } from '../utils/sessionViewPreview'; import './TerminalToolCard.scss'; const log = createLogger('TerminalToolCard'); @@ -199,7 +200,7 @@ function parseTerminalResult(raw: unknown, durationMs?: number): ParsedTerminalR const stderr = typeof record.stderr === 'string' ? record.stderr : ''; const combinedOutput = [stdout, stderr].filter((value) => value.length > 0).join('\n'); const outputField = typeof record.output === 'string' ? record.output : ''; - const output = outputField || combinedOutput; + const output = formatSessionViewPreviewText(outputField || combinedOutput); return { output, diff --git a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.test.tsx new file mode 100644 index 000000000..3413954f8 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.test.tsx @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { createTodoRenderItems } from './todoRenderItems'; + +describe('createTodoRenderItems', () => { + it('keeps React render keys unique when restored todos reuse ids', () => { + const items = createTodoRenderItems([ + { id: '[truncated for session view]', content: 'Phase 1', status: 'completed' }, + { id: '[truncated for session view]', content: 'Phase 2', status: 'completed' }, + { id: 'p3-2', content: 'Phase 3', status: 'pending' }, + ]); + + expect(new Set(items.map(item => item.key)).size).toBe(items.length); + expect(items.map(item => item.key)).toEqual([ + '[truncated for session view]-0', + '[truncated for session view]-1', + 'p3-2', + ]); + }); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx index 6ae22b4bd..da7bfb191 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx @@ -11,16 +11,9 @@ import { useToolCardHeightContract } from './useToolCardHeightContract'; import { useDialogTurnTodos } from '../hooks/useDialogTurnTodos'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; import { ToolCardStatusSlot } from './ToolCardStatusSlot'; +import { createTodoRenderItems, type TodoLike } from './todoRenderItems'; import './TodoWriteDisplay.scss'; -type TodoStatus = 'completed' | 'in_progress' | 'pending' | 'cancelled'; - -interface TodoLike { - id?: string | number; - content?: string; - status?: TodoStatus | string; -} - export const TodoWriteDisplay: React.FC = ({ toolItem, config, @@ -52,6 +45,11 @@ export const TodoWriteDisplay: React.FC = ({ return []; }, [partialParams, toolResult, isParamsStreaming, turnTodos]); + const todoRenderItems = useMemo( + () => createTodoRenderItems(todosToDisplay), + [todosToDisplay], + ); + const taskStats = useMemo(() => { if (todosToDisplay.length === 0) return { completed: 0, total: 0 }; const completed = todosToDisplay.filter((td) => td.status === 'completed').length; @@ -96,8 +94,8 @@ export const TodoWriteDisplay: React.FC = ({ }); }, [applyExpandedState, isExpanded, todosToDisplay.length]); - const renderTodoItem = (todo: TodoLike, index: number) => ( -
+ const renderTodoItem = (todo: TodoLike, key: string) => ( +
{todo.status === 'completed' && ( @@ -210,7 +208,7 @@ export const TodoWriteDisplay: React.FC = ({ const expandedContent = hasTodos ? (
- {todosToDisplay.map((todo, idx) => renderTodoItem(todo, idx))} + {todoRenderItems.map(({ todo, key }) => renderTodoItem(todo, key))}
) : undefined; diff --git a/src/web-ui/src/flow_chat/tool-cards/todoRenderItems.ts b/src/web-ui/src/flow_chat/tool-cards/todoRenderItems.ts new file mode 100644 index 000000000..88a130d31 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/todoRenderItems.ts @@ -0,0 +1,35 @@ +type TodoStatus = 'completed' | 'in_progress' | 'pending' | 'cancelled'; + +export interface TodoLike { + id?: string | number; + content?: string; + status?: TodoStatus | string; +} + +export interface TodoRenderItem { + key: string; + todo: TodoLike; +} + +export function createTodoRenderItems(todos: TodoLike[]): TodoRenderItem[] { + const idCounts = new Map(); + + for (const todo of todos) { + if (todo.id === undefined || todo.id === null) continue; + const id = String(todo.id); + idCounts.set(id, (idCounts.get(id) ?? 0) + 1); + } + + return todos.map((todo, index) => { + if (todo.id === undefined || todo.id === null) { + return { key: `todo-${index}`, todo }; + } + + const id = String(todo.id); + if ((idCounts.get(id) ?? 0) <= 1) { + return { key: id, todo }; + } + + return { key: `${id}-${index}`, todo }; + }); +} diff --git a/src/web-ui/src/flow_chat/utils/sessionViewPreview.test.ts b/src/web-ui/src/flow_chat/utils/sessionViewPreview.test.ts new file mode 100644 index 000000000..e9756e96a --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/sessionViewPreview.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatSessionViewPreviewText, + isOnlySessionViewPreviewText, + isSessionViewPreviewText, +} from './sessionViewPreview'; + +describe('sessionViewPreview', () => { + it('replaces legacy internal markers with user-facing preview text', () => { + expect(formatSessionViewPreviewText('abc\n...[truncated for session view]')) + .toBe('abc\n... Output truncated for session preview'); + expect(formatSessionViewPreviewText('[truncated for session view]')) + .toBe('Output omitted from session preview'); + }); + + it('detects marker-only preview output', () => { + expect(isOnlySessionViewPreviewText('[truncated for session view]')).toBe(true); + expect(isOnlySessionViewPreviewText('...[truncated for session view]')).toBe(true); + expect(isOnlySessionViewPreviewText('real output [truncated for session view]')).toBe(false); + expect(isSessionViewPreviewText('real output [truncated for session view]')).toBe(true); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/sessionViewPreview.ts b/src/web-ui/src/flow_chat/utils/sessionViewPreview.ts new file mode 100644 index 000000000..50a9960c9 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/sessionViewPreview.ts @@ -0,0 +1,28 @@ +const LEGACY_SESSION_VIEW_TRUNCATED_MARKER = '[truncated for session view]'; +const LEGACY_SESSION_VIEW_TRUNCATED_SUFFIX = '...[truncated for session view]'; +const SESSION_VIEW_TRUNCATED_MESSAGE = 'Output truncated for session preview'; +const SESSION_VIEW_OMITTED_MESSAGE = 'Output omitted from session preview'; + +export function isSessionViewPreviewText(value: unknown): value is string { + if (typeof value !== 'string') return false; + return value.includes(LEGACY_SESSION_VIEW_TRUNCATED_MARKER) || + value.includes(SESSION_VIEW_TRUNCATED_MESSAGE) || + value.includes(SESSION_VIEW_OMITTED_MESSAGE); +} + +export function isOnlySessionViewPreviewText(value: unknown): boolean { + if (typeof value !== 'string') return false; + const normalized = value.trim(); + return normalized === LEGACY_SESSION_VIEW_TRUNCATED_MARKER || + normalized === LEGACY_SESSION_VIEW_TRUNCATED_SUFFIX || + normalized === SESSION_VIEW_TRUNCATED_MESSAGE || + normalized === SESSION_VIEW_OMITTED_MESSAGE || + normalized === `... ${SESSION_VIEW_TRUNCATED_MESSAGE}`; +} + +export function formatSessionViewPreviewText(value: string): string { + return value + .replace(/\n\.\.\.\[truncated for session view\]/g, `\n... ${SESSION_VIEW_TRUNCATED_MESSAGE}`) + .replace(/\.\.\.\[truncated for session view\]/g, `... ${SESSION_VIEW_TRUNCATED_MESSAGE}`) + .replace(/\[truncated for session view\]/g, SESSION_VIEW_OMITTED_MESSAGE); +}