From ea392a26cc6c942a425a9a99736a5bbe5871f3b3 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 8 Sep 2026 14:52:20 +0300 Subject: [PATCH] feat(chat): collapse completed live activity with tool summaries Keep live history compact without changing sorted rendering. Reuse Activity Default, preserve final answers, and summarize tool-result diffs under an animated disclosure. Validated with the UI test suite, focused disclosure tests, UI type-check and lint, web production build, dead-code scan, and browser and animation fixtures. --- .../ui/src/components/chat/MessageList.tsx | 51 ++++- .../chat/components/LiveActivityCollapse.tsx | 63 ++++++ .../chat/components/LiveTurnActivity.test.tsx | 201 ++++++++++++++++++ .../chat/components/LiveTurnActivity.tsx | 86 ++++++++ .../components/chat/components/TurnItem.tsx | 5 +- .../chat/components/liveActivityContext.ts | 9 + .../chat/lib/turns/liveActivity.test.ts | 201 ++++++++++++++++++ .../components/chat/lib/turns/liveActivity.ts | 28 +++ .../chat/lib/turns/liveActivitySummary.ts | 179 ++++++++++++++++ .../components/chat/message/MessageBody.tsx | 23 +- .../chat/message/parts/DOCUMENTATION.md | 52 +++++ .../openchamber/OpenChamberVisualSettings.tsx | 8 +- packages/ui/src/lib/i18n/messages/de.ts | 9 + packages/ui/src/lib/i18n/messages/en.ts | 9 + packages/ui/src/lib/i18n/messages/es.ts | 9 + packages/ui/src/lib/i18n/messages/fr.ts | 9 + packages/ui/src/lib/i18n/messages/ja.ts | 9 + packages/ui/src/lib/i18n/messages/ko.ts | 9 + packages/ui/src/lib/i18n/messages/pl.ts | 9 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 9 + packages/ui/src/lib/i18n/messages/tr.ts | 9 + packages/ui/src/lib/i18n/messages/uk.ts | 9 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 9 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 9 + packages/ui/src/lib/settings/search.ts | 6 + packages/ui/src/types/bun-test.d.ts | 4 +- scripts/perf/animation-fixture.html | 29 ++- 27 files changed, 1032 insertions(+), 21 deletions(-) create mode 100644 packages/ui/src/components/chat/components/LiveActivityCollapse.tsx create mode 100644 packages/ui/src/components/chat/components/LiveTurnActivity.test.tsx create mode 100644 packages/ui/src/components/chat/components/LiveTurnActivity.tsx create mode 100644 packages/ui/src/components/chat/components/liveActivityContext.ts create mode 100644 packages/ui/src/components/chat/lib/turns/liveActivity.test.ts create mode 100644 packages/ui/src/components/chat/lib/turns/liveActivity.ts create mode 100644 packages/ui/src/components/chat/lib/turns/liveActivitySummary.ts diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index bef3cbcf..cad54a13 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -6,6 +6,8 @@ import ChatMessage from './ChatMessage'; import { filterVisibleParts, isEmptyTextPart } from './message/partUtils'; import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare'; import TurnItem from './components/TurnItem'; +import { LiveTurnActivity } from './components/LiveTurnActivity'; +import { getTurnsWithLaterAssistant, hasLiveActivity } from './lib/turns/liveActivity'; import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types'; import { useTurnRecords } from './hooks/useTurnRecords'; import { applyRetryOverlay } from './lib/turns/applyRetryOverlay'; @@ -343,9 +345,10 @@ type RenderEntry = previousMessage?: ChatMessageEntry; nextMessage?: ChatMessageEntry; } - | { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean; nextEntryFirstMessage?: ChatMessageEntry }; + | { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean; hasLaterAssistant?: boolean; nextEntryFirstMessage?: ChatMessageEntry }; -type TurnUiState = { isExpanded: boolean }; +type TurnUiState = { isExpanded: boolean; isLiveExpanded?: boolean }; +type ToggleTurnGroup = (turnId: string, mode?: 'sorted' | 'live') => void; @@ -412,12 +415,13 @@ MessageRow.displayName = 'MessageRow'; interface TurnBlockProps { turn: TurnRecord; + hasLaterAssistant?: boolean; isLastTurn: boolean; nextEntryFirstMessage?: ChatMessageEntry; sessionIsWorking: boolean; defaultActivityExpanded: boolean; turnUiStates: Map; - onToggleTurnGroup: (turnId: string) => void; + onToggleTurnGroup: ToggleTurnGroup; chatRenderMode: 'sorted' | 'live'; scrollToBottom?: () => void; stickyUserHeader?: boolean; @@ -430,6 +434,7 @@ interface TurnBlockProps { const TurnBlock = React.memo(({ turn, + hasLaterAssistant = false, isLastTurn, nextEntryFirstMessage, sessionIsWorking, @@ -447,6 +452,7 @@ const TurnBlock = React.memo(({ }: TurnBlockProps) => { const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); + const showReasoningTraces = useUIStore((state) => state.showReasoningTraces); const userMessageHidden = React.useMemo( () => isHiddenUserMessage(turn.userMessage, { planModeEnabled }), [planModeEnabled, turn.userMessage] @@ -455,6 +461,9 @@ const TurnBlock = React.memo(({ const handleToggleTurnGroup = React.useCallback(() => { onToggleTurnGroup(turn.turnId); }, [onToggleTurnGroup, turn.turnId]); + const handleToggleLiveActivity = React.useCallback(() => { + onToggleTurnGroup(turn.turnId, 'live'); + }, [onToggleTurnGroup, turn.turnId]); const messageOrder = React.useMemo(() => { const ordered = [turn.userMessage, ...turn.assistantMessages]; @@ -724,6 +733,15 @@ const TurnBlock = React.memo(({ turn={renderableTurn} stickyUserHeader={stickyUserHeader && !userMessageHidden} renderMessage={renderMessage} + assistantContent={chatRenderMode === 'live' && !defaultActivityExpanded && hasLiveActivity(turn, showReasoningTraces) ? ( + + ) : undefined} /> ); }); @@ -777,7 +795,7 @@ interface MessageListEntryProps { sessionIsWorking: boolean; defaultActivityExpanded: boolean; turnUiStates: Map; - onToggleTurnGroup: (turnId: string) => void; + onToggleTurnGroup: ToggleTurnGroup; chatRenderMode: 'sorted' | 'live'; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; onUserAnimationConsumed: (messageId: string) => void; @@ -833,6 +851,7 @@ const MessageListEntry = React.memo(({ return ( ; - onToggleTurnGroup: (turnId: string) => void; + onToggleTurnGroup: ToggleTurnGroup; chatRenderMode: 'sorted' | 'live'; showTurnChangedFiles: boolean; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; @@ -1090,7 +1109,7 @@ const StreamingTailContent: React.FC<{ sessionIsWorking: boolean; defaultActivityExpanded: boolean; turnUiStates: Map; - onToggleTurnGroup: (turnId: string) => void; + onToggleTurnGroup: ToggleTurnGroup; chatRenderMode: 'sorted' | 'live'; showTurnChangedFiles: boolean; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; @@ -1194,13 +1213,15 @@ const MessageList = React.forwardRef(({ React.useEffect(() => { setTurnUiStates(new Map()); - }, [activityRenderMode]); + }, [activityRenderMode, sessionKey]); - const toggleTurnGroup = React.useCallback((turnId: string) => { + const toggleTurnGroup = React.useCallback((turnId: string, mode: 'sorted' | 'live' = 'sorted') => { setTurnUiStates((previous) => { const next = new Map(previous); const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded }; - next.set(turnId, { isExpanded: !current.isExpanded }); + next.set(turnId, mode === 'live' + ? { ...current, isLiveExpanded: !current.isLiveExpanded } + : { ...current, isExpanded: !current.isExpanded }); return next; }); }, [defaultActivityExpanded]); @@ -1293,6 +1314,15 @@ const MessageList = React.forwardRef(({ planModeEnabled, }); const hasUngroupedStaticEntries = projection.ungroupedMessageIds.size > 0; + const tailHasAssistant = Boolean(streamingTurn?.assistantMessages.length); + const turnsWithLaterAssistant = React.useMemo(() => { + if (chatRenderMode !== 'live' || defaultActivityExpanded) return new Set(); + const retired = getTurnsWithLaterAssistant(staticTurns); + if (tailHasAssistant) { + for (const turn of staticTurns) retired.add(turn.turnId); + } + return retired; + }, [chatRenderMode, defaultActivityExpanded, staticTurns, tailHasAssistant]); const staticEntryMessages = hasUngroupedStaticEntries ? displayMessages : EMPTY_STATIC_ENTRY_MESSAGES; const staticEntryUngroupedIds = hasUngroupedStaticEntries ? projection.ungroupedMessageIds : EMPTY_UNGROUPED_MESSAGE_IDS; const staticRenderEntries = React.useMemo(() => streamPerfMeasure('ui.message_list.render_entries_ms', () => { @@ -1301,6 +1331,7 @@ const MessageList = React.forwardRef(({ key: `turn:${turn.turnId}`, turn, isLastTurn: turn.turnId === projection.lastTurnId, + hasLaterAssistant: turnsWithLaterAssistant.has(turn.turnId), })); if (staticEntryUngroupedIds.size === 0) { @@ -1334,7 +1365,7 @@ const MessageList = React.forwardRef(({ }); return orderedEntries; - }), [projection.lastTurnId, staticEntryMessages, staticEntryUngroupedIds, staticTurns]); + }), [projection.lastTurnId, staticEntryMessages, staticEntryUngroupedIds, staticTurns, turnsWithLaterAssistant]); const trailingStreamingEntry = React.useMemo(() => { if (streamingTurn) { diff --git a/packages/ui/src/components/chat/components/LiveActivityCollapse.tsx b/packages/ui/src/components/chat/components/LiveActivityCollapse.tsx new file mode 100644 index 00000000..ba79de72 --- /dev/null +++ b/packages/ui/src/components/chat/components/LiveActivityCollapse.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { animate } from 'motion'; + +interface LiveActivityCollapseProps { + expanded: boolean; + children: React.ReactNode; + id?: string; + animateOnMount?: boolean; +} + +export function LiveActivityCollapse({ expanded, children, id, animateOnMount = false }: LiveActivityCollapseProps) { + const ref = React.useRef(null); + const previousExpanded = React.useRef(expanded || animateOnMount); + const [retained, setRetained] = React.useState(expanded || animateOnMount); + const mounted = expanded || retained; + + React.useLayoutEffect(() => { + const element = ref.current; + if (!element || previousExpanded.current === expanded) return; + previousExpanded.current = expanded; + if (expanded) setRetained(true); + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + element.style.height = expanded ? 'auto' : '0px'; + element.style.overflow = expanded ? 'visible' : 'hidden'; + setRetained(expanded); + return; + } + element.style.height = expanded ? '0px' : `${element.scrollHeight}px`; + element.style.overflow = 'hidden'; + const animation = animate(element, { height: expanded ? 'auto' : '0px' }, { + duration: 0.18, + ease: [0.16, 1, 0.3, 1], + }); + let cancelled = false; + void animation.finished.then(() => { + if (cancelled) return; + element.style.height = expanded ? 'auto' : '0px'; + element.style.overflow = expanded ? 'visible' : 'hidden'; + setRetained(expanded); + }).catch(() => undefined); + return () => { + cancelled = true; + animation.stop(); + }; + }, [expanded]); + + return ( +
+ {mounted ? children : null} +
+ ); +} diff --git a/packages/ui/src/components/chat/components/LiveTurnActivity.test.tsx b/packages/ui/src/components/chat/components/LiveTurnActivity.test.tsx new file mode 100644 index 00000000..8457098b --- /dev/null +++ b/packages/ui/src/components/chat/components/LiveTurnActivity.test.tsx @@ -0,0 +1,201 @@ +import React, { act } from 'react'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { plugin } from 'bun'; +import { pathToFileURL } from 'node:url'; +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { createRoot, type Root } from 'react-dom/client'; +import { Window } from 'happy-dom'; +import { createOpencodeClient, type Part, type AssistantMessage } from '@opencode-ai/sdk/v2'; +import { I18nProvider } from '@/lib/i18n'; +import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; +import type { RuntimeAPIs } from '@/lib/api/types'; +import { SyncProvider } from '@/sync/sync-context'; +import { useUIStore } from '@/stores/useUIStore'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { projectTurnRecords } from '../lib/turns/projectTurnRecords'; +import type { ChatMessageEntry, TurnRecord } from '../lib/turns/types'; +import { LiveTurnActivity } from './LiveTurnActivity'; + +plugin({ + name: 'live-activity-worker-url', + setup(build) { + build.onLoad({ filter: /markdown-shiki\.worker\.ts\?worker&url$/ }, ({ path }) => ({ + contents: `export default ${JSON.stringify(pathToFileURL(path.split('?')[0]).href)};`, loader: 'js', + })); + // Expand Vite's eager asset glob into the same real file URL map for + // Bun. This is a loader transform, not a replacement of the logo hook. + build.onLoad({ filter: /useProviderLogo\.ts$/ }, ({ path }) => { + const folder = resolve(dirname(path), '../assets/provider-logos'); + const logos = Object.fromEntries(readdirSync(folder).filter((name) => name.endsWith('.svg')) + .map((name) => [`../assets/provider-logos/${name}`, pathToFileURL(resolve(folder, name)).href])); + const contents = readFileSync(path, 'utf8').replace(/import\.meta\.glob\([\s\S]*?\);/, `${JSON.stringify(logos)};`); + return { contents, loader: 'ts' }; + }); + }, +}); + +const unavailable = (): never => { throw new Error('Activity rendering must not call runtime APIs'); }; +const runtimeApis: RuntimeAPIs = { + runtime: { platform: 'web', isDesktop: false, isVSCode: false }, + get terminal() { return unavailable(); }, + get git() { return unavailable(); }, + get files() { return unavailable(); }, + get settings() { return unavailable(); }, + get permissions() { return unavailable(); }, + get notifications() { return unavailable(); }, + get tools() { return unavailable(); }, +}; +const sdk = createOpencodeClient({ baseUrl: 'http://localhost', fetch: async () => new Response('[]', { headers: { 'Content-Type': 'application/json' } }) }); +let MessageBody: typeof import('../message/MessageBody').default; + +function assistant(id: string, parts: Part[], finish?: string): ChatMessageEntry { + const info: AssistantMessage = { + id, sessionID: 'session', role: 'assistant', parentID: 'user', time: { created: 2, completed: finish ? 3 : undefined }, + modelID: 'model', providerID: 'provider', mode: 'build', agent: 'build', path: { cwd: '/project', root: '/project' }, + cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, finish, + }; + return { info, parts }; +} +function text(id: string, content: string): Part { + return { type: 'text', id, text: content, sessionID: 'session', messageID: 'message' }; +} +const readPart: Part = { + type: 'tool', tool: 'read', id: 'read', callID: 'read', sessionID: 'session', messageID: 'progress', + state: { status: 'completed', input: { filePath: '/project/source.ts' }, output: 'code', title: 'Read', metadata: {}, time: { start: 1, end: 2 } }, +}; +function turn(messages: ChatMessageEntry[]): TurnRecord { + return projectTurnRecords([{ + info: { id: 'user', sessionID: 'session', role: 'user', time: { created: 1 }, agent: 'build', model: { providerID: 'provider', modelID: 'model' } }, + parts: [text('request', 'Request')], + }, ...messages]).turns[0]; +} + +function Harness({ record, retired = false }: { record: TurnRecord; retired?: boolean }) { + const [expanded, setExpanded] = React.useState(false); + const renderMessage = (message: ChatMessageEntry) => ( +
+ undefined} expandedTools={new Set()} + onToggleTool={() => undefined} onShowPopup={() => undefined} streamPhase="completed" allowAnimation={false} + hasTextContent={message.parts.some((part) => part.type === 'text')} showReasoningTraces + turnGroupingContext={{ + turnId: 'user', isFirstAssistantInTurn: message === record.assistantMessages[0], + isLastAssistantInTurn: message === record.assistantMessages.at(-1), + isLatestTurn: true, isWorking: false, hasTools: record.hasTools, hasReasoning: record.hasReasoning, + }} + /> +
+ ); + return + + + setExpanded((value) => !value)} renderMessage={renderMessage} /> + + + ; +} + +describe('live Activity with the real message body', () => { + let root: Root; + let container: HTMLDivElement; + let restore: () => void; + beforeEach(async () => { + const win = new Window({ url: 'http://localhost', settings: { device: { prefersReducedMotion: 'reduce' } } }); + const globals = { + window: win, document: win.document, navigator: win.navigator, localStorage: win.localStorage, + customElements: win.customElements, + Node: win.Node, NodeList: win.NodeList, Element: win.Element, HTMLElement: win.HTMLElement, SVGElement: win.SVGElement, + HTMLAnchorElement: win.HTMLAnchorElement, + MutationObserver: win.MutationObserver, ResizeObserver: win.ResizeObserver, + requestAnimationFrame: win.requestAnimationFrame.bind(win), cancelAnimationFrame: win.cancelAnimationFrame.bind(win), + getComputedStyle: win.getComputedStyle.bind(win), IS_REACT_ACT_ENVIRONMENT: true, + }; + const previous = Object.keys(globals).map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const); + for (const [name, value] of Object.entries(globals)) Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + // Reduced motion makes the disclosure lifecycle deterministic without + // replacing the real component or animation module. + restore = () => { + for (const [name, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + }; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + useUIStore.setState({ chatRenderMode: 'live', collapsibleThinkingBlocks: false, showSplitAssistantMessageActions: false }); + useDirectoryStore.setState({ currentDirectory: '/project' }); + MessageBody = (await import('../message/MessageBody')).default; + }); + afterEach(async () => { + await act(async () => root.unmount()); + restore(); + }); + + test('keeps active prose and tools visible; stop folds history but leaves the answer', async () => { + const progress = assistant('progress', [text('progress-text', 'Checking the source'), readPart], 'tool-calls'); + await act(async () => root.render()); + expect(container.textContent).toContain('Checking the source'); + expect(container.querySelector('[aria-controls]')).toBeNull(); + expect(container.textContent).not.toContain('Activity'); + const final = assistant('final', [text('final-text', 'The final answer')], 'stop'); + await act(async () => root.render()); + expect(container.textContent).toContain('The final answer'); + expect(container.textContent).not.toContain('Checking the source'); + const header = container.querySelector('button[aria-controls]'); + expect(header?.getAttribute('aria-expanded')).toBe('false'); + expect(header?.textContent).toContain('Explored codebase'); + await act(async () => header?.click()); + expect(header?.textContent).toContain('Explored codebase'); + expect(container.textContent).toContain('Checking the source'); + expect(container.textContent).toContain('The final answer'); + await act(async () => root.render()); + expect(header?.getAttribute('aria-expanded')).toBe('true'); + }); + + test('keeps thinking in the final message inside Activity, not outside with the answer', async () => { + const thinking: Part = { type: 'reasoning', id: 'thinking', messageID: 'final', sessionID: 'session', text: 'Private reasoning content', time: { start: 1, end: 2 } }; + const final = assistant('final', [thinking, text('final-text', 'Public answer')], 'stop'); + await act(async () => root.render()); + expect(container.textContent).toContain('Public answer'); + expect(container.textContent).not.toContain('Private reasoning content'); + await act(async () => container.querySelector('button[aria-controls]')?.click()); + expect(container.textContent).toContain('Private reasoning content'); + expect(container.textContent).toContain('Public answer'); + }); + + test('an interrupted turn folds all prose without fabricating a final answer', async () => { + const record = turn([assistant('progress', [text('progress-text', 'Still working'), readPart], 'tool-calls')]); + await act(async () => root.render()); + expect(container.textContent).toContain('Still working'); + expect(container.textContent).not.toContain('Activity'); + await act(async () => root.render()); + expect(container.textContent).not.toContain('Still working'); + expect(container.textContent).toContain('Activity'); + await act(async () => container.querySelector('button[aria-controls]')?.click()); + expect(container.textContent).toContain('Still working'); + }); + + test('keeps file statistics visible when expanded and uses an ASCII minus', async () => { + const edit: Part = { + type: 'tool', tool: 'edit', id: 'edit', callID: 'edit', sessionID: 'session', messageID: 'progress', + state: { status: 'completed', input: { filePath: '/project/source.ts' }, output: '', title: 'Edit', + metadata: { diff: '@@ -1,1 +1,2 @@\n-old\n+new\n+added' }, time: { start: 1, end: 2 } }, + }; + await act(async () => root.render()); + const header = container.querySelector('button[aria-controls]'); + expect(header?.textContent).toContain('Changed 1 file'); + expect(header?.textContent).toContain('+2/-1'); + await act(async () => header?.click()); + expect(header?.textContent).toContain('Changed 1 file'); + expect(header?.textContent).toContain('+2/-1'); + }); +}); diff --git a/packages/ui/src/components/chat/components/LiveTurnActivity.tsx b/packages/ui/src/components/chat/components/LiveTurnActivity.tsx new file mode 100644 index 00000000..0add7566 --- /dev/null +++ b/packages/ui/src/components/chat/components/LiveTurnActivity.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { Button } from '@/components/ui/button'; +import { useI18n } from '@/lib/i18n'; +import type { ChatMessageEntry, TurnRecord } from '../lib/turns/types'; +import { getLiveFinalMessage } from '../lib/turns/liveActivity'; +import { summarizeLiveActivity } from '../lib/turns/liveActivitySummary'; +import { LiveActivityCollapse } from './LiveActivityCollapse'; +import { LiveFinalActivityContext } from './liveActivityContext'; + +interface LiveTurnActivityProps { + turn: TurnRecord; + hasLaterAssistant: boolean; + expanded: boolean; + onToggle: () => void; + renderMessage: (message: ChatMessageEntry) => React.ReactNode; +} + +export function LiveTurnActivity({ turn, hasLaterAssistant, expanded, onToggle, renderMessage }: LiveTurnActivityProps) { + const { t } = useI18n(); + const contentId = React.useId(); + const finalContentId = React.useId(); + const finalMessage = getLiveFinalMessage(turn.assistantMessages); + const settled = Boolean(finalMessage) || hasLaterAssistant; + const isExpanded = !settled || expanded; + const previouslySettled = React.useRef(settled); + const animateFinalCollapse = settled && !previouslySettled.current; + React.useLayoutEffect(() => { previouslySettled.current = settled; }, [settled]); + const finalContext = React.useMemo(() => finalMessage ? { + messageId: finalMessage.info.id, expanded: isExpanded, contentId: finalContentId, animateCollapse: animateFinalCollapse, + } : null, [finalMessage, isExpanded, finalContentId, animateFinalCollapse]); + // No diff parsing at token frequency. The report is only shown once the + // turn settles; later authoritative tool metadata can refine it. + const summary = React.useMemo(() => settled ? summarizeLiveActivity(turn.assistantMessages) : null, + [settled, turn.assistantMessages]); + const fileLabel = summary && summary.files > 0 + ? t(summary.files === 1 ? 'chat.liveActivity.changedFile' : 'chat.liveActivity.changedFiles', { count: summary.files }) + : null; + const details = summary ? [ + summary.explored ? t('chat.liveActivity.explored') : null, + summary.commands > 0 ? t(summary.commands === 1 ? 'chat.liveActivity.ranCommand' : 'chat.liveActivity.ranCommands', { count: summary.commands }) : null, + summary.researched ? t('chat.liveActivity.researched') : null, + summary.subagents > 0 ? t(summary.subagents === 1 ? 'chat.liveActivity.usedSubagent' : 'chat.liveActivity.usedSubagents', { count: summary.subagents }) : null, + ].filter(Boolean).join(' · ') : ''; + const label = ( + <> + + {t('chat.liveActivity.title')} + {settled ? : null} + {fileLabel ? ( + + {fileLabel} + {summary?.hasCompleteDiff && (summary.additions > 0 || summary.deletions > 0) ? ( + + +{summary.additions} + + -{summary.deletions} + + ) : null} + + ) : null} + {details ? {fileLabel ? '· ' : ''}{details} : null} + + ); + const headerClass = 'w-full justify-start normal-case !pl-px !pr-2 text-[var(--tools-description)] hover:!bg-transparent active:!bg-transparent'; + return ( +
+ {settled ? ( +
+
+ +
+
+ ) : null} + + {turn.assistantMessages.map((message) => message === finalMessage ? null : renderMessage(message))} + + + {finalMessage ? renderMessage(finalMessage) : null} + +
+ ); +} diff --git a/packages/ui/src/components/chat/components/TurnItem.tsx b/packages/ui/src/components/chat/components/TurnItem.tsx index 8508ec0a..d8963d0f 100644 --- a/packages/ui/src/components/chat/components/TurnItem.tsx +++ b/packages/ui/src/components/chat/components/TurnItem.tsx @@ -7,6 +7,7 @@ interface TurnItemProps { turn: Turn; stickyUserHeader?: boolean; renderMessage: (message: ChatMessageEntry) => React.ReactNode; + assistantContent?: React.ReactNode; } /** @@ -23,7 +24,7 @@ const STICKY_HEADER_BACKGROUND: React.CSSProperties = { 'linear-gradient(to bottom, var(--surface-background) calc(100% - 0.75rem), transparent)', }; -const TurnItem: React.FC = ({ turn, stickyUserHeader = true, renderMessage }) => { +const TurnItem: React.FC = ({ turn, stickyUserHeader = true, renderMessage, assistantContent }) => { return (
= ({ turn, stickyUserHeader = true, rend renderMessage(turn.userMessage) )} - + {assistantContent ?? }
); }; diff --git a/packages/ui/src/components/chat/components/liveActivityContext.ts b/packages/ui/src/components/chat/components/liveActivityContext.ts new file mode 100644 index 00000000..7af8d3ec --- /dev/null +++ b/packages/ui/src/components/chat/components/liveActivityContext.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react'; + +/** Only the final message splits its non-text parts from its answer/footer. */ +export const LiveFinalActivityContext = createContext<{ + messageId: string; + expanded: boolean; + contentId: string; + animateCollapse: boolean; +} | null>(null); diff --git a/packages/ui/src/components/chat/lib/turns/liveActivity.test.ts b/packages/ui/src/components/chat/lib/turns/liveActivity.test.ts new file mode 100644 index 00000000..3b6adb52 --- /dev/null +++ b/packages/ui/src/components/chat/lib/turns/liveActivity.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from 'bun:test'; +import type { AssistantMessage, Part, ToolPart, ToolStateCompleted } from '@opencode-ai/sdk/v2'; +import { getLiveFinalMessage, getTurnsWithLaterAssistant, hasLiveActivity } from './liveActivity'; +import { projectTurnRecords } from './projectTurnRecords'; +import { summarizeLiveActivity } from './liveActivitySummary'; +import type { ChatMessageEntry } from './types'; + +function assistant(id: string, parts: Part[], options: Partial = {}): ChatMessageEntry { + return { + info: { + id, sessionID: 'session', role: 'assistant', parentID: 'user', + time: { created: 2 }, modelID: 'model', providerID: 'provider', mode: 'build', agent: 'build', + path: { cwd: '/project', root: '/project' }, cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + ...options, + }, + parts, + }; +} + +function user(id = 'user', hidden = false): ChatMessageEntry { + return { + info: { id, sessionID: 'session', role: 'user', time: { created: 1 }, agent: 'build', model: { providerID: 'provider', modelID: 'model' } }, + parts: hidden ? [] : [text(`Request ${id}`)], + }; +} + +function text(content: string): Part { + return { type: 'text', id: content, messageID: 'message', sessionID: 'session', text: content }; +} + +function tool(id: string, name: string, options: { + status?: 'completed' | 'error' | 'running'; + input?: ToolStateCompleted['input']; + metadata?: ToolStateCompleted['metadata']; + error?: string; +} = {}): ToolPart { + const common = { input: options.input ?? {}, metadata: options.metadata ?? {}, time: { start: 1, end: 2 } }; + const state: ToolPart['state'] = options.status === 'error' + ? { ...common, status: 'error', error: options.error ?? 'failed' } + : options.status === 'running' + ? { ...common, status: 'running' } + : { ...common, status: 'completed', output: '', title: name }; + return { + id, callID: id, type: 'tool', tool: name, messageID: 'message', sessionID: 'session', + state, + }; +} + +const diff = '@@ -1,1 +1,2 @@\n-before\n+after\n+added'; + +describe('live turn boundaries', () => { + test('a queued user message does not collapse the previous turn', () => { + const turns = projectTurnRecords([user(), assistant('a', [tool('read', 'read')]), user('next')]).turns; + expect(getTurnsWithLaterAssistant(turns).size).toBe(0); + }); + + test('an assistant with the next visible parent retires the previous turn', () => { + const turns = projectTurnRecords([ + user(), assistant('a', [text('Checking'), tool('read', 'read')]), user('next'), + assistant('b', [tool('bash', 'bash')], { parentID: 'next' }), + ]).turns; + expect([...getTurnsWithLaterAssistant(turns)]).toEqual(['user']); + expect(getLiveFinalMessage(turns[0].assistantMessages)).toBeUndefined(); + }); + + test('hidden user continuations keep their visible turn open', () => { + const turns = projectTurnRecords([ + user(), assistant('a', [tool('read', 'read')]), user('hidden', true), + assistant('b', [tool('bash', 'bash')], { parentID: 'hidden' }), + ], { mergeHiddenUserTurns: { planModeEnabled: false } }).turns; + expect(turns).toHaveLength(1); + expect(getTurnsWithLaterAssistant(turns).size).toBe(0); + }); + + test('only stop text is a final answer, not a tool step, compaction or earlier stop', () => { + const final = assistant('final', [text('Done')], { finish: 'stop' }); + expect(getLiveFinalMessage([final])).toBe(final); + expect(getLiveFinalMessage([assistant('progress', [text('Checking')], { finish: 'tool-calls' })])).toBeUndefined(); + expect(getLiveFinalMessage([assistant('compact', [text('Summary')], { finish: 'stop', summary: true })])).toBeUndefined(); + expect(getLiveFinalMessage([final, assistant('continued', [tool('read', 'read')])])).toBeUndefined(); + expect(getLiveFinalMessage([assistant('question', [text('Which?'), tool('question', 'question', { status: 'running' })])])).toBeUndefined(); + }); + + test('activity eligibility follows visible sorted activity rather than any assistant prose', () => { + const reasoning: Part = { type: 'reasoning', id: 'thinking', messageID: 'a', sessionID: 'session', text: 'Thinking', time: { start: 1, end: 2 } }; + const turn = projectTurnRecords([user(), assistant('a', [reasoning, text('Done')], { finish: 'stop' })]).turns[0]; + expect(hasLiveActivity(turn, false)).toBe(false); + expect(hasLiveActivity(turn, true)).toBe(true); + expect(hasLiveActivity(projectTurnRecords([user(), assistant('a', [text('Hello')], { finish: 'stop' })]).turns[0], true)).toBe(false); + }); +}); + +describe('live activity report', () => { + test('groups exploration and web calls without pretending their counts are file counts', () => { + const result = summarizeLiveActivity([assistant('a', [ + ...['read', 'list', 'glob', 'grep', 'lsp', 'skill', 'webfetch', 'websearch', 'codesearch', 'perplexity'].map((name) => tool(name, name)), + tool('bash1', 'bash', { input: { command: 'first && second' } }), + tool('bash2', 'bash'), + tool('task1', 'task', { metadata: { sessionId: 'child' } }), + tool('task2', 'task', { metadata: { sessionId: 'child' } }), + tool('task3', 'task', { metadata: { sessionId: 'other-child' } }), + ])]); + expect(result).toMatchObject({ explored: true, researched: true, commands: 2, subagents: 2, files: 0 }); + }); + + test('does not invent meanings for managed tools, MCP names or unknown aliases', () => { + const result = summarizeLiveActivity([assistant('a', [ + ...['question', 'todowrite', 'plan_exit', 'StructuredOutput', 'openchamber', 'openchamber_web', 'openchamber_memory', 'linear_save_issue', 'mcp.edit'].map((name) => tool(name, name)), + ])]); + expect(result).toMatchObject({ explored: false, researched: false, commands: 0, subagents: 0, files: 0 }); + }); + + test('counts each command call once, including a confirmed nonzero exit but not a permission refusal', () => { + const command = tool('bash', 'bash'); + const result = summarizeLiveActivity([assistant('a', [ + command, command, + tool('failed', 'bash', { status: 'error', error: 'exit 1', metadata: { exit: 1 } }), + tool('denied', 'bash', { status: 'error', error: 'Permission denied' }), + tool('running', 'bash', { status: 'running' }), + ])]); + expect(result.commands).toBe(2); + }); + + test('sums actual call diffs while deduplicating paths and duplicate call records', () => { + const first = tool('edit1', 'edit', { input: { filePath: './src/a.ts' }, metadata: { diff } }); + const result = summarizeLiveActivity([assistant('a', [ + first, first, + tool('edit2', 'edit', { input: { filePath: '/project/src/a.ts' }, metadata: { diff: '@@ -1,1 +1,0 @@\n-after' } }), + ])]); + expect(result).toMatchObject({ files: 1, additions: 2, deletions: 2, hasCompleteDiff: true }); + }); + + test('takes all patch files and never double-counts their top-level diff', () => { + const result = summarizeLiveActivity([assistant('a', [tool('patch', 'apply_patch', { metadata: { + diff, + files: [ + { filePath: '/project/a', patch: diff, type: 'update' }, + { filePath: '/project/b', diff: '@@ -1,1 +1,0 @@\n-deleted', type: 'delete' }, + { filePath: '/project/c', additions: 3, deletions: 0, type: 'add' }, + ], + } })])]); + expect(result).toMatchObject({ files: 3, additions: 5, deletions: 2, hasCompleteDiff: true }); + }); + + test('a rename preserves the identity of a file already edited in the turn', () => { + const result = summarizeLiveActivity([assistant('a', [ + tool('edit', 'edit', { input: { filePath: 'old.ts' }, metadata: { diff } }), + tool('move', 'apply_patch', { metadata: { files: [{ filePath: '/project/old.ts', movePath: '/project/new.ts', additions: 0, deletions: 0 }] } }), + tool('edit-again', 'edit', { input: { filePath: 'new.ts' }, metadata: { diff } }), + ])]); + expect(result).toMatchObject({ files: 1, additions: 4, deletions: 2 }); + }); + + test('uses the whole-call diff when per-file stats are missing, without adding partial numbers', () => { + const result = summarizeLiveActivity([assistant('a', [tool('patch', 'apply_patch', { metadata: { + diff: `${diff}\n@@ -1,1 +1,0 @@\n-deleted`, + files: [{ filePath: '/project/a', patch: diff }, { filePath: '/project/b' }], + } })])]); + expect(result).toMatchObject({ files: 2, additions: 2, deletions: 2, hasCompleteDiff: true }); + }); + + test('write content is not a diff and partial stats are not shown as a complete total', () => { + const result = summarizeLiveActivity([assistant('a', [ + tool('edit', 'edit', { input: { filePath: 'a' }, metadata: { diff } }), + tool('write', 'write', { input: { filePath: 'b', content: 'one\ntwo\nthree' } }), + ])]); + expect(result).toMatchObject({ files: 2, hasCompleteDiff: false }); + }); + + test('rejects truncated diff counts and counts source lines resembling diff headers', () => { + expect(summarizeLiveActivity([assistant('a', [tool('edit', 'edit', { input: { filePath: 'a' }, metadata: { diff: '@@ -1,1 +1,2 @@\n-old\n+incomplete' } })])]).hasCompleteDiff).toBe(false); + expect(summarizeLiveActivity([assistant('a', [tool('edit', 'edit', { input: { filePath: 'a' }, metadata: { diff: '@@ -1,1 +1,1 @@\n---source\n+++source' } })])])).toMatchObject({ additions: 1, deletions: 1 }); + }); + + test('failed edits and malformed metadata cannot erase another valid change', () => { + const result = summarizeLiveActivity([assistant('a', [ + tool('bad', 'edit', { status: 'error', error: 'failed', input: { filePath: 'bad' }, metadata: { diff } }), + tool('good', 'edit', { input: { filePath: 'good' }, metadata: { diff, files: 'invalid' } }), + ])]); + expect(result).toMatchObject({ files: 1, additions: 2, deletions: 1, hasCompleteDiff: true }); + }); + + test('normalizes absolute dot segments and Windows path spelling', () => { + expect(summarizeLiveActivity([assistant('unix', [ + tool('one', 'edit', { input: { filePath: '/project/src/../a' }, metadata: { diff } }), + tool('two', 'edit', { input: { filePath: 'a' }, metadata: { diff } }), + ])]).files).toBe(1); + expect(summarizeLiveActivity([assistant('windows', [ + tool('one', 'edit', { input: { filePath: 'C:\\Project\\A.ts' }, metadata: { diff } }), + tool('two', 'edit', { input: { filePath: 'c:/project/./a.ts' }, metadata: { diff } }), + ], { path: { cwd: 'C:/Project', root: 'C:/Project' } })]).files).toBe(1); + }); + + test('a confirmed no-op is not a changed file, but creating an empty file is', () => { + expect(summarizeLiveActivity([assistant('a', [tool('patch', 'apply_patch', { metadata: { files: [ + { filePath: '/project/noop', additions: 0, deletions: 0, type: 'update' }, + { filePath: '/project/empty', additions: 0, deletions: 0, type: 'add' }, + ] } })])])).toMatchObject({ files: 1, additions: 0, deletions: 0, hasCompleteDiff: true }); + }); +}); diff --git a/packages/ui/src/components/chat/lib/turns/liveActivity.ts b/packages/ui/src/components/chat/lib/turns/liveActivity.ts new file mode 100644 index 00000000..c3c3295c --- /dev/null +++ b/packages/ui/src/components/chat/lib/turns/liveActivity.ts @@ -0,0 +1,28 @@ +import type { ChatMessageEntry, TurnRecord } from './types'; + +/** A queued user message alone does not retire the previous turn. */ +export function getTurnsWithLaterAssistant(turns: readonly TurnRecord[]): Set { + const retired = new Set(); + let hasLaterAssistant = false; + for (let index = turns.length - 1; index >= 0; index--) { + const turn = turns[index]; + if (hasLaterAssistant) retired.add(turn.turnId); + hasLaterAssistant ||= turn.assistantMessages.length > 0; + } + return retired; +} + +export function getLiveFinalMessage(messages: readonly ChatMessageEntry[]): ChatMessageEntry | undefined { + const last = messages.at(-1); + // Do not use projectTurnSummary's intermediate-text fallback. Compaction + // summaries are not user-facing final answers either. + return last?.info.role === 'assistant' && last.info.finish === 'stop' && !last.info.summary + && last.parts.some((part) => part.type === 'text' && part.text.trim().length > 0) + ? last : undefined; +} + +export function hasLiveActivity(turn: TurnRecord, showReasoning: boolean): boolean { + return turn.activitySegments.some((segment) => segment.parts.some((activity) => ( + activity.kind === 'tool' || (showReasoning && activity.kind === 'reasoning') + ))); +} diff --git a/packages/ui/src/components/chat/lib/turns/liveActivitySummary.ts b/packages/ui/src/components/chat/lib/turns/liveActivitySummary.ts new file mode 100644 index 00000000..03c86391 --- /dev/null +++ b/packages/ui/src/components/chat/lib/turns/liveActivitySummary.ts @@ -0,0 +1,179 @@ +import { z } from 'zod'; +import { normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils'; +import type { ChatMessageEntry } from './types'; + +const patchTextSchema = z.string().regex(/\S/); +const patchSchema = z.union([patchTextSchema, z.object({ patch: patchTextSchema }).transform((value) => value.patch)]); +const optionalText = z.string().trim().min(1).optional().catch(undefined); +const optionalCount = z.number().int().nonnegative().optional().catch(undefined); +const fileSchema = z.object({ + file: optionalText, + filePath: optionalText, + relativePath: optionalText, + movePath: optionalText, + type: optionalText, + patch: patchSchema.optional().catch(undefined), + diff: patchSchema.optional().catch(undefined), + additions: optionalCount, + deletions: optionalCount, +}); +// Tool metadata is an external boundary. Parse only the fields whose meaning +// is established by our edit/patch renderers; unrelated or malformed fields +// must not erase other valid calls from the report. +const metadataSchema = z.object({ + files: z.array(fileSchema.nullable().catch(null)).optional().catch(undefined), + filediff: fileSchema.optional().catch(undefined), + patch: patchSchema.optional().catch(undefined), + diff: patchSchema.optional().catch(undefined), + sessionId: optionalText, + exit: z.number().optional().catch(undefined), +}); +const inputSchema = z.object({ + filePath: optionalText, + file_path: optionalText, + path: optionalText, +}); + +const changeTools = new Set(['edit', 'multiedit', 'write', 'apply_patch']); +const explorationTools = new Set(['read', 'list', 'grep', 'glob', 'lsp', 'skill']); +const webTools = new Set(['websearch', 'perplexity', 'codesearch', 'webfetch']); +const commandTools = new Set(['bash', 'shell', 'cmd', 'terminal']); + +export interface LiveActivitySummary { + files: number; + additions: number; + deletions: number; + hasCompleteDiff: boolean; + explored: boolean; + commands: number; + researched: boolean; + subagents: number; +} + +function countPatch(patch: string | undefined): { additions: number; deletions: number } | undefined { + if (!patch) return undefined; + let additions = 0; + let deletions = 0; + let hasHunk = false; + let oldRemaining = 0; + let newRemaining = 0; + // Count hunk bodies, not file headers. A source line beginning with ++ or + // -- is still a real added/deleted line inside a hunk. + for (const line of patch.split('\n')) { + const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line); + if (hunk) { + if (oldRemaining !== 0 || newRemaining !== 0) return undefined; + hasHunk = true; + oldRemaining = Number(hunk[2] ?? 1); + newRemaining = Number(hunk[4] ?? 1); + } else if (oldRemaining > 0 || newRemaining > 0) { + if (line.startsWith('+') && newRemaining > 0) { + additions++; + newRemaining--; + } else if (line.startsWith('-') && oldRemaining > 0) { + deletions++; + oldRemaining--; + } else if (line.startsWith(' ') && oldRemaining > 0 && newRemaining > 0) { + oldRemaining--; + newRemaining--; + } else if (!line.startsWith('\\ No newline')) { + return undefined; + } + } + } + return hasHunk && oldRemaining === 0 && newRemaining === 0 ? { additions, deletions } : undefined; +} + +export function summarizeLiveActivity(messages: readonly ChatMessageEntry[]): LiveActivitySummary { + const summary: LiveActivitySummary = { + files: 0, additions: 0, deletions: 0, hasCompleteDiff: true, + explored: false, commands: 0, researched: false, subagents: 0, + }; + const changedFiles = new Set(); + const subagents = new Set(); + const seenCalls = new Set(); + for (const message of messages) { + const cwd = message.info.role === 'assistant' ? message.info.path?.cwd ?? '' : ''; + const resolvePath = (path: string) => { + const absolute = normalizeFilePath(cwd ? toAbsoluteFilePath(cwd, path) : path); + if (/^[A-Za-z]:\//.test(absolute)) { + return toAbsoluteFilePath(absolute.slice(0, 3), absolute.slice(3)).toLowerCase(); + } + if (absolute.startsWith('//')) { + const [server, share, ...parts] = absolute.slice(2).split('/'); + return toAbsoluteFilePath(`//${server}/${share}`, parts.join('/')).toLowerCase(); + } + return absolute.startsWith('/') ? toAbsoluteFilePath('/', absolute.slice(1)) : absolute; + }; + for (const part of message.parts) { + if (part.type !== 'tool') continue; + const callKey = `${message.info.id}:${part.callID || part.id}`; + if (seenCalls.has(callKey)) continue; + seenCalls.add(callKey); + const state = part.state; + if (state.status !== 'completed' && state.status !== 'error') continue; + const tool = part.tool.trim().toLowerCase(); + const metadata = metadataSchema.safeParse(state.metadata).data; + if (commandTools.has(tool) && (state.status === 'completed' || metadata?.exit !== undefined)) { + summary.commands++; + } + if (state.status !== 'completed') continue; + summary.explored ||= explorationTools.has(tool); + summary.researched ||= webTools.has(tool); + if (tool === 'task' && metadata?.sessionId) subagents.add(metadata.sessionId); + if (!changeTools.has(tool)) continue; + + const input = inputSchema.safeParse(state.input).data; + if (metadata?.files?.some((file) => file === null)) summary.hasCompleteDiff = false; + const entries = metadata?.files?.filter((file) => file !== null); + const files = entries?.length ? entries : [metadata?.filediff ?? {}]; + let missingFileDiff = false; + let callAdditions = 0; + let callDeletions = 0; + const callPaths = new Set(); + for (const file of files) { + const originalPath = file.filePath ?? file.file ?? file.relativePath + ?? (tool !== 'apply_patch' ? input?.filePath ?? input?.file_path ?? input?.path : undefined); + const path = file.movePath ?? originalPath; + if (!path) { + summary.hasCompleteDiff = false; + continue; + } + const normalizedPath = resolvePath(path); + if (callPaths.has(normalizedPath)) continue; + callPaths.add(normalizedPath); + const stats = countPatch(file.patch ?? file.diff) + ?? (file.additions !== undefined && file.deletions !== undefined + ? { additions: file.additions, deletions: file.deletions } : undefined); + if (stats && stats.additions === 0 && stats.deletions === 0 + && !file.movePath && file.type !== 'add' && file.type !== 'delete') continue; + // A rename moves an existing identity rather than counting it + // again when the same file was edited earlier in this turn. + if (file.movePath && originalPath) changedFiles.delete(resolvePath(originalPath)); + changedFiles.add(normalizedPath); + if (!stats) { + missingFileDiff = true; + } else { + callAdditions += stats.additions; + callDeletions += stats.deletions; + } + } + if (missingFileDiff) { + // The top-level patch describes the entire call. Use it instead + // of (never in addition to) any per-file numbers already found. + const fallback = countPatch(metadata?.patch ?? metadata?.diff); + if (fallback) { + callAdditions = fallback.additions; + callDeletions = fallback.deletions; + } else { + summary.hasCompleteDiff = false; + } + } + summary.additions += callAdditions; + summary.deletions += callDeletions; + } + } + summary.files = changedFiles.size; + summary.subagents = subagents.size; + return summary; +} diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index beb09f6e..89f0ebe3 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -40,6 +40,8 @@ import { ToolRevealOnMount } from './parts/ToolRevealOnMount'; import { StaticToolRow } from './parts/ProgressiveGroup'; import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils'; import TurnActivity from '../components/TurnActivity'; +import { LiveActivityCollapse } from '../components/LiveActivityCollapse'; +import { LiveFinalActivityContext } from '../components/liveActivityContext'; import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; @@ -1326,6 +1328,7 @@ const AssistantMessageBody = React.memo(({ const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const vscodeApi = useRuntimeAPIs().vscode; const isSortedRenderMode = chatRenderMode === 'sorted'; + const liveFinalActivity = React.useContext(LiveFinalActivityContext); const collapsedPreviewCount = 7; const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false; const hasStopFinish = messageFinish === 'stop'; @@ -1729,7 +1732,10 @@ const AssistantMessageBody = React.memo(({ const shouldRenderStandaloneActionsAfterContent = shouldShowStandaloneMessageActions && lastRenderableTextPartIndex < 0; const renderedParts = React.useMemo(() => { - const rendered: React.ReactNode[] = []; + const answerRendered: React.ReactNode[] = []; + const activityRendered: React.ReactNode[] = []; + let rendered = answerRendered; + const splitLiveActivity = !isSortedRenderMode && liveFinalActivity?.messageId === messageId && hasStopFinish; let hasRenderedAnswerText = false; const isFinalLiveAnswer = chatRenderMode === 'live' && isLastAssistantInTurn && hasStopFinish; const hasEarlierVisibleActivity = isFinalLiveAnswer && Boolean(turnGroupingContext?.activityParts?.some((activity) => { @@ -1820,6 +1826,7 @@ const AssistantMessageBody = React.memo(({ let i = 0; while (i < visibleParts.length) { const part = visibleParts[i]; + rendered = splitLiveActivity && part.type !== 'text' ? activityRendered : answerRendered; if (part.type === 'text') { const activity = activityByPart.get(part); @@ -1831,7 +1838,7 @@ const AssistantMessageBody = React.memo(({ i += 1; continue; } - if (isFinalLiveAnswer && !hasRenderedAnswerText && (rendered.length > 0 || hasEarlierVisibleActivity || turnGroupingContext?.hasEarlierAssistantText)) { + if (isFinalLiveAnswer && !hasRenderedAnswerText && (rendered.length > 0 || activityRendered.length > 0 || hasEarlierVisibleActivity || turnGroupingContext?.hasEarlierAssistantText)) { rendered.push(
+ {activityRendered} + , + ...answerRendered, + ]; + } + return answerRendered; }, [ activityByPart, activityGroupSegmentsForMessage, @@ -2003,6 +2019,7 @@ const AssistantMessageBody = React.memo(({ isMobile, isActivityOwnerMessage, isSortedRenderMode, + liveFinalActivity, isLastAssistantInTurn, hasStopFinish, lastRenderableTextPartIndex, diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 3946f014..84cca7c2 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -46,11 +46,63 @@ Use this doc when you ask an agent to change tool/header/description behavior. - `ReasoningPart.tsx` - Thinking block UI (`ReasoningTimelineBlock`), summary + optional duration. +- `components/LiveTurnActivity.tsx` (relative to the chat folder) + - Owns the optional live-only turn disclosure. `MessageList` enables it when + Activity Default is Collapsed and the turn has visible Activity content. + - `components/LiveActivityCollapse.tsx` owns the finite height transition; + `components/liveActivityContext.ts` scopes the final message's non-text + disclosure without changing sorted message context or tool rendering. + - `lib/turns/liveActivity.ts` owns final-answer and interruption boundaries. + - `lib/turns/liveActivitySummary.ts` derives the report from tool results. + - `JustificationBlock.tsx` - Justification block wrapper over `ReasoningTimelineBlock`. ## Current important behavior +### Optional live history disclosure + +Activity Default is shared by the settings UI in both render modes. In live +mode, Expanded preserves the original timeline without a turn disclosure. +Collapsed adds one Activity header after completion or interruption while preserving the original live rows, +their order, and their individual controls. It adds no tool subgroups, side +line, height cap, or inner scroller. Sorted rendering keeps its existing path +and its own per-turn expansion state. + +The active turn stays open without an Activity header. A final assistant message with `finish: stop` +collapses the earlier messages and the final message's non-text parts, keeping +the answer and its existing footer outside. Intermediate-text summary fallback +and compaction summaries never become final answers. An older turn without a +final answer collapses once a later visible turn has an assistant response; +a queued user message alone is not enough. Hidden user continuations retain +the visible-turn mapping established by `projectTurnRecords`. + +Manual expansion survives later metadata updates and timeline virtualization +within the session. The disclosure uses a finite 180ms height transition, +respects reduced motion, and delegates end pinning to the existing timeline. +It never calls scroll-to-bottom. Collapsed history does not mount its hidden +message bodies; initial history loads do not animate collapse. + +The header retains its report when expanded and has no hover background. Its +left inset matches sorted Activity. Diff deletions use the ASCII hyphen. +The header reports five categories: changed files, codebase +exploration, commands, web research, and subagents. Narrow chat columns only +show file changes. Exploration and research are flags, not synthetic counts. +Subagents count distinct child session IDs; commands count calls, not shell +subcommands. Unknown tools and administrative tools stay in the disclosure +without a guessed summary category. + +File statistics come exclusively from successful edit/write/patch tool +results, not user-message summary diffs or the current workspace Git diff. +Unique normalized paths determine file count; renames preserve identities. +Line totals sum performed edits, including lines later removed by another +call. Per-file patches/counts take precedence over a whole-call patch; the two +representations are never added together. Missing or truncated diffs suppress +the line total rather than presenting a partial total as complete. Write input +content is not evidence of added lines. Repeated records of one call count once. + +### Message parts + - Assistant markdown treats raw HTML as inert visible text. The final generated HTML is sanitized as defense in depth, with script and style elements forbidden, so message content cannot inject active DOM or application-wide diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 45a18da1..a15ab63c 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -691,7 +691,7 @@ export const OpenChamberVisualSettings: React.FC || shouldShow('userMessageRendering') || shouldShow('chatRenderMode') || shouldShow('messageTransport') - || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') + || shouldShow('activityRenderMode') || shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || (shouldShow('promptNavigatorEnabled') && !isVSCode) @@ -713,7 +713,7 @@ export const OpenChamberVisualSettings: React.FC || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('enterToSend'); const showBehaviorDisplaySettings = shouldShow('chatRenderMode') - || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted'); + || shouldShow('activityRenderMode'); const showTransportSection = shouldShow('messageTransport'); const showBehaviorMessageOptions = shouldShow('userMessageRendering') || shouldShow('mermaidRendering') @@ -1685,8 +1685,8 @@ export const OpenChamberVisualSettings: React.FC )} - {shouldShow('activityRenderMode') && chatRenderMode === 'sorted' && ( - + {shouldShow('activityRenderMode') && ( + {ACTIVITY_RENDER_MODE_OPTIONS.map((option) => ( = { + 'chat.liveActivity.title': 'Actividad', + 'chat.liveActivity.changedFile': '{count} archivo modificado', + 'chat.liveActivity.changedFiles': '{count} archivos modificados', + 'chat.liveActivity.explored': 'Código explorado', + 'chat.liveActivity.ranCommand': '{count} comando ejecutado', + 'chat.liveActivity.ranCommands': '{count} comandos ejecutados', + 'chat.liveActivity.researched': 'Investigación en la web realizada', + 'chat.liveActivity.usedSubagent': '{count} subagente utilizado', + 'chat.liveActivity.usedSubagents': '{count} subagentes utilizados', 'sessions.sidebar.projectAction.active': 'Acción del proyecto en curso', ...settingsDict, ...linearIssuePickerI18n.es, diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 63811baa..0531746c 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -3,6 +3,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict = { + 'chat.liveActivity.title': 'Activité', + 'chat.liveActivity.changedFile': '{count} fichier modifié', + 'chat.liveActivity.changedFiles': '{count} fichiers modifiés', + 'chat.liveActivity.explored': 'Base de code explorée', + 'chat.liveActivity.ranCommand': '{count} commande exécutée', + 'chat.liveActivity.ranCommands': '{count} commandes exécutées', + 'chat.liveActivity.researched': 'Recherche sur le web effectuée', + 'chat.liveActivity.usedSubagent': '{count} sous-agent utilisé', + 'chat.liveActivity.usedSubagents': '{count} sous-agents utilisés', 'sessions.sidebar.projectAction.active': 'Action du projet en cours', ...settingsDict, ...linearIssuePickerI18n.fr, diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 7aa504fa..226ee92e 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -4,6 +4,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { + 'chat.liveActivity.title': 'アクティビティ', + 'chat.liveActivity.changedFile': '{count} ファイルを変更', + 'chat.liveActivity.changedFiles': '{count} ファイルを変更', + 'chat.liveActivity.explored': 'コードベースを調査', + 'chat.liveActivity.ranCommand': '{count} コマンドを実行', + 'chat.liveActivity.ranCommands': '{count} コマンドを実行', + 'chat.liveActivity.researched': 'ウェブを調査', + 'chat.liveActivity.usedSubagent': '{count} サブエージェントを使用', + 'chat.liveActivity.usedSubagents': '{count} サブエージェントを使用', 'sessions.sidebar.projectAction.active': 'プロジェクトアクション実行中', ...settingsDict, ...linearIssuePickerI18n.ja, diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 4d0b0bd5..44eb539a 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -4,6 +4,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { + 'chat.liveActivity.title': '활동', + 'chat.liveActivity.changedFile': '파일 {count}개 변경', + 'chat.liveActivity.changedFiles': '파일 {count}개 변경', + 'chat.liveActivity.explored': '코드베이스 탐색', + 'chat.liveActivity.ranCommand': '명령 {count}개 실행', + 'chat.liveActivity.ranCommands': '명령 {count}개 실행', + 'chat.liveActivity.researched': '웹 조사', + 'chat.liveActivity.usedSubagent': '하위 에이전트 {count}개 사용', + 'chat.liveActivity.usedSubagents': '하위 에이전트 {count}개 사용', 'sessions.sidebar.projectAction.active': '프로젝트 작업 실행 중', ...settingsDict, ...linearIssuePickerI18n.ko, diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 73b44d64..c31f7159 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -4,6 +4,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { + 'chat.liveActivity.title': 'Aktywność', + 'chat.liveActivity.changedFile': 'Zmieniono {count} plik', + 'chat.liveActivity.changedFiles': 'Zmienione pliki: {count}', + 'chat.liveActivity.explored': 'Przeanalizowano bazę kodu', + 'chat.liveActivity.ranCommand': 'Wykonano {count} polecenie', + 'chat.liveActivity.ranCommands': 'Wykonane polecenia: {count}', + 'chat.liveActivity.researched': 'Przeszukano internet', + 'chat.liveActivity.usedSubagent': 'Użyto {count} subagenta', + 'chat.liveActivity.usedSubagents': 'Użyci subagenci: {count}', 'sessions.sidebar.projectAction.active': 'Trwa wykonywanie akcji projektu', ...settingsDict, ...linearIssuePickerI18n.pl, diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 737c81a1..51a1b481 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -4,6 +4,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { + 'chat.liveActivity.title': 'Atividade', + 'chat.liveActivity.changedFile': '{count} arquivo alterado', + 'chat.liveActivity.changedFiles': '{count} arquivos alterados', + 'chat.liveActivity.explored': 'Base de código explorada', + 'chat.liveActivity.ranCommand': '{count} comando executado', + 'chat.liveActivity.ranCommands': '{count} comandos executados', + 'chat.liveActivity.researched': 'Pesquisa na web realizada', + 'chat.liveActivity.usedSubagent': '{count} subagente utilizado', + 'chat.liveActivity.usedSubagents': '{count} subagentes utilizados', 'sessions.sidebar.projectAction.active': 'Ação do projeto em execução', ...settingsDict, ...linearIssuePickerI18n['pt-BR'], diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index 47336d0a..6b048903 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -3,6 +3,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict = { + 'chat.liveActivity.title': 'Etkinlik', + 'chat.liveActivity.changedFile': '{count} dosya değiştirildi', + 'chat.liveActivity.changedFiles': '{count} dosya değiştirildi', + 'chat.liveActivity.explored': 'Kod tabanı incelendi', + 'chat.liveActivity.ranCommand': '{count} komut çalıştırıldı', + 'chat.liveActivity.ranCommands': '{count} komut çalıştırıldı', + 'chat.liveActivity.researched': 'Web araştırması yapıldı', + 'chat.liveActivity.usedSubagent': '{count} alt ajan kullanıldı', + 'chat.liveActivity.usedSubagents': '{count} alt ajan kullanıldı', 'sessions.sidebar.projectAction.active': 'Proje eylemi çalışıyor', ...settingsDict, ...linearIssuePickerI18n.tr, diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 0f9132a1..45d038be 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -4,6 +4,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { + 'chat.liveActivity.title': 'Дії', + 'chat.liveActivity.changedFile': 'Змінено {count} файл', + 'chat.liveActivity.changedFiles': 'Змінено файлів: {count}', + 'chat.liveActivity.explored': 'Досліджено кодову базу', + 'chat.liveActivity.ranCommand': 'Виконано {count} команду', + 'chat.liveActivity.ranCommands': 'Виконано команд: {count}', + 'chat.liveActivity.researched': 'Проведено пошук в інтернеті', + 'chat.liveActivity.usedSubagent': 'Залучено {count} сабагента', + 'chat.liveActivity.usedSubagents': 'Залучено сабагентів: {count}', 'sessions.sidebar.projectAction.active': 'Виконується дія проєкту', ...settingsDict, ...linearIssuePickerI18n.uk, diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 2bb46645..774be72d 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -4,6 +4,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { + 'chat.liveActivity.title': '活动', + 'chat.liveActivity.changedFile': '更改了 {count} 个文件', + 'chat.liveActivity.changedFiles': '更改了 {count} 个文件', + 'chat.liveActivity.explored': '探索了代码库', + 'chat.liveActivity.ranCommand': '运行了 {count} 条命令', + 'chat.liveActivity.ranCommands': '运行了 {count} 条命令', + 'chat.liveActivity.researched': '进行了网络研究', + 'chat.liveActivity.usedSubagent': '使用了 {count} 个子代理', + 'chat.liveActivity.usedSubagents': '使用了 {count} 个子代理', 'sessions.sidebar.projectAction.active': '项目操作正在运行', ...settingsDict, ...linearIssuePickerI18n['zh-CN'], diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index a8a367f2..e667260d 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -4,6 +4,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n'; import { linearPanelI18n } from './linear-panel.i18n'; export const dict: Record = { + 'chat.liveActivity.title': '活動', + 'chat.liveActivity.changedFile': '變更了 {count} 個檔案', + 'chat.liveActivity.changedFiles': '變更了 {count} 個檔案', + 'chat.liveActivity.explored': '探索了程式碼庫', + 'chat.liveActivity.ranCommand': '執行了 {count} 條命令', + 'chat.liveActivity.ranCommands': '執行了 {count} 條命令', + 'chat.liveActivity.researched': '進行了網路研究', + 'chat.liveActivity.usedSubagent': '使用了 {count} 個子代理', + 'chat.liveActivity.usedSubagents': '使用了 {count} 個子代理', 'sessions.sidebar.projectAction.active': '專案操作正在執行', ...settingsDict, ...linearIssuePickerI18n['zh-TW'], diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index fd35bdb7..1c679ab2 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -32,6 +32,12 @@ interface SettingsSearchAvailabilityContext extends SettingsRuntimeContext { } const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ + { + id: 'chat.activity-default', + page: 'chat', + titleKey: 'settings.openchamber.visual.section.activityDefault', + keywords: ['activity', 'collapsed', 'expanded', 'live', 'tools', 'history'], + }, { id: 'appearance.language', page: 'appearance', diff --git a/packages/ui/src/types/bun-test.d.ts b/packages/ui/src/types/bun-test.d.ts index dd3e502e..ccd0eb78 100644 --- a/packages/ui/src/types/bun-test.d.ts +++ b/packages/ui/src/types/bun-test.d.ts @@ -13,6 +13,8 @@ declare module "bun:test" { toThrow(expected?: string | RegExp | (new (...args: never[]) => unknown)): void; toContain(expected: unknown): void; toBeDefined(): void; + toBeUndefined(): void; + toMatchObject(expected: unknown): void; rejects: { toThrow(expected?: string | RegExp | (new (...args: never[]) => unknown)): Promise; }; @@ -65,7 +67,7 @@ declare module "bun" { setup(build: { onLoad(options: { filter: RegExp }, callback: (args: { path: string }) => { contents: string; - loader: "js"; + loader: "js" | "ts"; }): void; }): void; }): void; diff --git a/scripts/perf/animation-fixture.html b/scripts/perf/animation-fixture.html index a4b08ce0..a41e92c4 100644 --- a/scripts/perf/animation-fixture.html +++ b/scripts/perf/animation-fixture.html @@ -91,6 +91,10 @@ overflow: visible; display: block; } + + .v-activity-static, .v-activity-collapse { display: block; } + .v-activity-static .cell, .v-activity-collapse .cell { width: 680px; height: auto; overflow: hidden; } + .activity-row { height: 18px; display: flex; gap: 12px; } @@ -108,16 +112,39 @@ grid.className = 'grid v-' + variant; const usesWrapper = variant.endsWith('-wrapper'); const usesButton = variant === 'ctx-button' || variant === 'ctx-sibling-translatez'; + const usesActivity = variant === 'activity-static' || variant === 'activity-collapse'; for (let index = 0; index < count; index += 1) { const cell = document.createElement('div'); cell.className = 'cell'; const spinner = SPINNER.replace(''; + if (usesActivity) { + cell.innerHTML = Array.from({ length: 80 }, (_, row) => + '
Read filesrc/module-' + row + '.tsCompleted
' + ).join(''); + } + else if (usesWrapper) cell.innerHTML = '' + SPINNER + ''; else if (usesButton) cell.innerHTML = ''; else cell.innerHTML = SPINNER; grid.appendChild(cell); } + // Two content regions per turn at most: prior messages and non-text parts + // of the final message. Exercise the same 180ms height transition, with no + // continuous animation between disclosure changes. The static variant has + // identical content for the baseline. + if (variant === 'activity-collapse') { + let expanded = true; + setInterval(() => { + for (const cell of grid.children) { + const height = cell.scrollHeight; + cell.animate({ height: expanded ? [height + 'px', '0px'] : ['0px', height + 'px'] }, { + duration: 180, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'forwards', + }); + } + expanded = !expanded; + }, 2000); + } + if (filler > 0) { const container = document.createElement('div'); container.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;';