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.
This commit is contained in:
@@ -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<string, TurnUiState>;
|
||||
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) ? (
|
||||
<LiveTurnActivity
|
||||
turn={renderableTurn}
|
||||
hasLaterAssistant={hasLaterAssistant}
|
||||
expanded={turnUiState.isLiveExpanded === true}
|
||||
onToggle={handleToggleLiveActivity}
|
||||
renderMessage={renderMessage}
|
||||
/>
|
||||
) : undefined}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -777,7 +795,7 @@ interface MessageListEntryProps {
|
||||
sessionIsWorking: boolean;
|
||||
defaultActivityExpanded: boolean;
|
||||
turnUiStates: Map<string, TurnUiState>;
|
||||
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 (
|
||||
<TurnBlock
|
||||
turn={entry.turn}
|
||||
hasLaterAssistant={entry.hasLaterAssistant}
|
||||
isLastTurn={entry.isLastTurn}
|
||||
nextEntryFirstMessage={entry.nextEntryFirstMessage}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
@@ -861,7 +880,7 @@ type TimelineRowContextValue = {
|
||||
stickyUserHeader: boolean;
|
||||
defaultActivityExpanded: boolean;
|
||||
turnUiStates: Map<string, TurnUiState>;
|
||||
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<string, TurnUiState>;
|
||||
onToggleTurnGroup: (turnId: string) => void;
|
||||
onToggleTurnGroup: ToggleTurnGroup;
|
||||
chatRenderMode: 'sorted' | 'live';
|
||||
showTurnChangedFiles: boolean;
|
||||
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
|
||||
@@ -1194,13 +1213,15 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
|
||||
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<MessageListHandle, MessageListProps>(({
|
||||
planModeEnabled,
|
||||
});
|
||||
const hasUngroupedStaticEntries = projection.ungroupedMessageIds.size > 0;
|
||||
const tailHasAssistant = Boolean(streamingTurn?.assistantMessages.length);
|
||||
const turnsWithLaterAssistant = React.useMemo(() => {
|
||||
if (chatRenderMode !== 'live' || defaultActivityExpanded) return new Set<string>();
|
||||
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<RenderEntry[]>(() => streamPerfMeasure('ui.message_list.render_entries_ms', () => {
|
||||
@@ -1301,6 +1331,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
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<MessageListHandle, MessageListProps>(({
|
||||
});
|
||||
|
||||
return orderedEntries;
|
||||
}), [projection.lastTurnId, staticEntryMessages, staticEntryUngroupedIds, staticTurns]);
|
||||
}), [projection.lastTurnId, staticEntryMessages, staticEntryUngroupedIds, staticTurns, turnsWithLaterAssistant]);
|
||||
|
||||
const trailingStreamingEntry = React.useMemo<RenderEntry | undefined>(() => {
|
||||
if (streamingTurn) {
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
aria-hidden={!expanded}
|
||||
inert={!expanded}
|
||||
data-live-activity-content="true"
|
||||
style={{
|
||||
height: mounted ? 'auto' : 0,
|
||||
overflow: mounted ? 'visible' : 'hidden',
|
||||
overflowAnchor: 'none',
|
||||
}}
|
||||
>
|
||||
{mounted ? children : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string>\([\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) => (
|
||||
<div key={message.info.id} data-fixture-message={message.info.id}>
|
||||
<MessageBody
|
||||
messageId={message.info.id} parts={message.parts} isUser={false}
|
||||
isMessageCompleted={message.info.role === 'assistant' && Boolean(message.info.finish)}
|
||||
messageFinish={message.info.role === 'assistant' ? message.info.finish : undefined}
|
||||
isMobile={false} copiedCode={null} onCopyCode={() => 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,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return <RuntimeAPIContext.Provider value={runtimeApis}>
|
||||
<SyncProvider sdk={sdk} directory="/project">
|
||||
<I18nProvider>
|
||||
<LiveTurnActivity turn={record} hasLaterAssistant={retired} expanded={expanded}
|
||||
onToggle={() => setExpanded((value) => !value)} renderMessage={renderMessage} />
|
||||
</I18nProvider>
|
||||
</SyncProvider>
|
||||
</RuntimeAPIContext.Provider>;
|
||||
}
|
||||
|
||||
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(<Harness record={turn([progress])} />));
|
||||
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(<Harness record={turn([progress, final])} />));
|
||||
expect(container.textContent).toContain('The final answer');
|
||||
expect(container.textContent).not.toContain('Checking the source');
|
||||
const header = container.querySelector<HTMLButtonElement>('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(<Harness record={turn([progress, { ...final, parts: [...final.parts] }])} />));
|
||||
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(<Harness record={turn([final])} />));
|
||||
expect(container.textContent).toContain('Public answer');
|
||||
expect(container.textContent).not.toContain('Private reasoning content');
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('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(<Harness record={record} />));
|
||||
expect(container.textContent).toContain('Still working');
|
||||
expect(container.textContent).not.toContain('Activity');
|
||||
await act(async () => root.render(<Harness record={record} retired />));
|
||||
expect(container.textContent).not.toContain('Still working');
|
||||
expect(container.textContent).toContain('Activity');
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('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(<Harness record={turn([
|
||||
assistant('progress', [edit], 'tool-calls'),
|
||||
assistant('final', [text('answer', 'Done')], 'stop'),
|
||||
])} />));
|
||||
const header = container.querySelector<HTMLButtonElement>('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');
|
||||
});
|
||||
});
|
||||
@@ -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 = (
|
||||
<>
|
||||
<Icon name="stack" className="size-3.5 shrink-0 text-[var(--tools-icon)]" />
|
||||
<span className="shrink-0 font-semibold text-[var(--tools-title)]">{t('chat.liveActivity.title')}</span>
|
||||
{settled ? <Icon name={isExpanded ? 'arrow-down-s' : 'arrow-right-s'} className="size-3 shrink-0" /> : null}
|
||||
{fileLabel ? (
|
||||
<span className="flex min-w-0 items-center gap-1 typography-meta @min-[640px]:shrink-0">
|
||||
<span className="truncate">{fileLabel}</span>
|
||||
{summary?.hasCompleteDiff && (summary.additions > 0 || summary.deletions > 0) ? (
|
||||
<span className="shrink-0 tabular-nums">
|
||||
<span className="text-[var(--status-success)]">+{summary.additions}</span>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span className="text-[var(--status-error)]">-{summary.deletions}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{details ? <span className="hidden min-w-0 flex-1 truncate text-left typography-meta @min-[640px]:inline" title={details}>{fileLabel ? '· ' : ''}{details}</span> : null}
|
||||
</>
|
||||
);
|
||||
const headerClass = 'w-full justify-start normal-case !pl-px !pr-2 text-[var(--tools-description)] hover:!bg-transparent active:!bg-transparent';
|
||||
return (
|
||||
<div className="relative z-0" data-live-turn-activity={turn.turnId}>
|
||||
{settled ? (
|
||||
<div className="chat-message-column @container">
|
||||
<div className="mt-1 mb-2">
|
||||
<Button variant="ghost" size="sm" className={headerClass} onClick={onToggle}
|
||||
aria-expanded={isExpanded} aria-controls={finalMessage ? `${contentId} ${finalContentId}` : contentId}>
|
||||
{label}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<LiveActivityCollapse expanded={isExpanded} id={contentId}>
|
||||
{turn.assistantMessages.map((message) => message === finalMessage ? null : renderMessage(message))}
|
||||
</LiveActivityCollapse>
|
||||
<LiveFinalActivityContext.Provider value={finalContext}>
|
||||
{finalMessage ? renderMessage(finalMessage) : null}
|
||||
</LiveFinalActivityContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<TurnItemProps> = ({ turn, stickyUserHeader = true, renderMessage }) => {
|
||||
const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, renderMessage, assistantContent }) => {
|
||||
return (
|
||||
<section
|
||||
className="relative w-full"
|
||||
@@ -44,7 +45,7 @@ const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, rend
|
||||
renderMessage(turn.userMessage)
|
||||
)}
|
||||
|
||||
<TurnAssistantBlock assistantMessages={turn.assistantMessages} renderMessage={renderMessage} />
|
||||
{assistantContent ?? <TurnAssistantBlock assistantMessages={turn.assistantMessages} renderMessage={renderMessage} />}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -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<AssistantMessage> = {}): 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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
const retired = new Set<string>();
|
||||
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')
|
||||
)));
|
||||
}
|
||||
@@ -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<string>();
|
||||
const subagents = new Set<string>();
|
||||
const seenCalls = new Set<string>();
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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(
|
||||
<div
|
||||
key={`final-answer-divider-${messageId}`}
|
||||
@@ -1989,7 +1996,16 @@ const AssistantMessageBody = React.memo(({
|
||||
});
|
||||
});
|
||||
|
||||
return rendered;
|
||||
if (splitLiveActivity && liveFinalActivity) {
|
||||
return [
|
||||
<LiveActivityCollapse key="final-message-activity" expanded={liveFinalActivity.expanded}
|
||||
id={liveFinalActivity.contentId} animateOnMount={liveFinalActivity.animateCollapse}>
|
||||
{activityRendered}
|
||||
</LiveActivityCollapse>,
|
||||
...answerRendered,
|
||||
];
|
||||
}
|
||||
return answerRendered;
|
||||
}, [
|
||||
activityByPart,
|
||||
activityGroupSegmentsForMessage,
|
||||
@@ -2003,6 +2019,7 @@ const AssistantMessageBody = React.memo(({
|
||||
isMobile,
|
||||
isActivityOwnerMessage,
|
||||
isSortedRenderMode,
|
||||
liveFinalActivity,
|
||||
isLastAssistantInTurn,
|
||||
hasStopFinish,
|
||||
lastRenderableTextPartIndex,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -691,7 +691,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|| 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<OpenChamberVisualSettingsProps>
|
||||
|| (!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<OpenChamberVisualSettingsProps>
|
||||
</SettingsControlGroup>
|
||||
)}
|
||||
|
||||
{shouldShow('activityRenderMode') && chatRenderMode === 'sorted' && (
|
||||
<SettingsControlGroup title={t('settings.openchamber.visual.section.activityDefault')}>
|
||||
{shouldShow('activityRenderMode') && (
|
||||
<SettingsControlGroup title={t('settings.openchamber.visual.section.activityDefault')} settingsItem="chat.activity-default">
|
||||
<SettingsRadioGroup aria-label={t('settings.openchamber.visual.section.activityDefaultAria')}>
|
||||
{ACTIVITY_RENDER_MODE_OPTIONS.map((option) => (
|
||||
<SettingsRadioOption
|
||||
|
||||
@@ -3,6 +3,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict = {
|
||||
'chat.liveActivity.title': 'Aktivität',
|
||||
'chat.liveActivity.changedFile': '{count} Datei geändert',
|
||||
'chat.liveActivity.changedFiles': '{count} Dateien geändert',
|
||||
'chat.liveActivity.explored': 'Codebasis untersucht',
|
||||
'chat.liveActivity.ranCommand': '{count} Befehl ausgeführt',
|
||||
'chat.liveActivity.ranCommands': '{count} Befehle ausgeführt',
|
||||
'chat.liveActivity.researched': 'Im Web recherchiert',
|
||||
'chat.liveActivity.usedSubagent': '{count} Unteragent eingesetzt',
|
||||
'chat.liveActivity.usedSubagents': '{count} Unteragenten eingesetzt',
|
||||
'sessions.sidebar.projectAction.active': 'Projektaktion aktiv',
|
||||
...settingsDict,
|
||||
...linearIssuePickerI18n.de,
|
||||
|
||||
@@ -3,6 +3,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict = {
|
||||
'chat.liveActivity.title': 'Activity',
|
||||
'chat.liveActivity.changedFile': 'Changed {count} file',
|
||||
'chat.liveActivity.changedFiles': 'Changed {count} files',
|
||||
'chat.liveActivity.explored': 'Explored codebase',
|
||||
'chat.liveActivity.ranCommand': 'Ran {count} command',
|
||||
'chat.liveActivity.ranCommands': 'Ran {count} commands',
|
||||
'chat.liveActivity.researched': 'Researched the web',
|
||||
'chat.liveActivity.usedSubagent': 'Used {count} subagent',
|
||||
'chat.liveActivity.usedSubagents': 'Used {count} subagents',
|
||||
'sessions.sidebar.projectAction.active': 'Project action active',
|
||||
...settingsDict,
|
||||
...linearIssuePickerI18n.en,
|
||||
|
||||
@@ -4,6 +4,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'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,
|
||||
|
||||
@@ -4,6 +4,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'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,
|
||||
|
||||
@@ -4,6 +4,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'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,
|
||||
|
||||
@@ -4,6 +4,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'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,
|
||||
|
||||
@@ -4,6 +4,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'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'],
|
||||
|
||||
@@ -4,6 +4,15 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
Vendored
+3
-1
@@ -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<void>;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -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('<svg', '<svg class="spin"');
|
||||
if (usesWrapper) cell.innerHTML = '<span class="wrap">' + SPINNER + '</span>';
|
||||
if (usesActivity) {
|
||||
cell.innerHTML = Array.from({ length: 80 }, (_, row) =>
|
||||
'<div class="activity-row"><span>Read file</span><code>src/module-' + row + '.ts</code><span>Completed</span></div>'
|
||||
).join('');
|
||||
}
|
||||
else if (usesWrapper) cell.innerHTML = '<span class="wrap">' + SPINNER + '</span>';
|
||||
else if (usesButton) cell.innerHTML = '<button>' + spinner + '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" fill="#456"/></svg></button>';
|
||||
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;';
|
||||
|
||||
Reference in New Issue
Block a user