diff --git a/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js b/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js index 6c724331fdd..a589d3fdbed 100644 --- a/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js +++ b/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js @@ -581,6 +581,54 @@ function normalizeRawInput(value) { return undefined; } +/** + * Extract locations from rawInput or toolCallResult for file-related tool calls. + * This ensures the exported data matches ACP format, enabling file links in UI. + * + * @param {object|undefined} rawInput - The raw input arguments of the tool call + * @param {object|undefined} toolCallResult - The tool call result object + * @returns {Array<{path: string, line?: number}>|undefined} - Locations array or undefined + */ +function extractLocations(rawInput, toolCallResult) { + const locations = []; + + // Extract from rawInput - common path field names used by various tools + if (rawInput && typeof rawInput === 'object') { + // read_file, write_file use absolute_path + if (typeof rawInput.absolute_path === 'string' && rawInput.absolute_path) { + locations.push({ path: rawInput.absolute_path }); + } + // edit tool uses file_path + else if (typeof rawInput.file_path === 'string' && rawInput.file_path) { + locations.push({ path: rawInput.file_path }); + } + // some tools use just 'path' + else if (typeof rawInput.path === 'string' && rawInput.path) { + locations.push({ path: rawInput.path }); + } + // glob/grep tools use 'pattern' with optional 'path' as search root + else if (typeof rawInput.pattern === 'string' && rawInput.pattern) { + // For search tools, the pattern itself isn't a file path, skip + } + // run_shell_command might have 'command' but no file path + } + + // Extract from toolCallResult.resultDisplay if available + if (toolCallResult && typeof toolCallResult === 'object') { + const display = toolCallResult.resultDisplay; + if (display && typeof display === 'object') { + if (typeof display.fileName === 'string' && display.fileName) { + // Avoid duplicates + if (!locations.some((loc) => loc.path === display.fileName)) { + locations.push({ path: display.fileName }); + } + } + } + } + + return locations.length > 0 ? locations : undefined; +} + function extractDiffContent(resultDisplay) { if (!resultDisplay || typeof resultDisplay !== 'object') return null; const display = resultDisplay; @@ -799,6 +847,7 @@ function convertChatRecordsToSessionData(records) { typeof fc.id === 'string' && fc.id ? fc.id : `${toolName || 'tool'}-${record.uuid}`; + const rawInput = normalizeRawInput(fc.args); const toolCallMessage = { uuid: record.uuid, parentUuid: record.parentUuid, @@ -810,7 +859,8 @@ function convertChatRecordsToSessionData(records) { kind: resolveToolKind(toolName), title: resolveToolTitle(toolName), status: 'in_progress', - rawInput: normalizeRawInput(fc.args), + rawInput, + locations: extractLocations(rawInput, undefined), timestamp: Date.parse(record.timestamp), }, }; @@ -845,6 +895,7 @@ function convertChatRecordsToSessionData(records) { status: toolCallResult.error ? 'failed' : 'completed', rawInput, content, + locations: extractLocations(rawInput, toolCallResult), timestamp: Date.parse(record.timestamp), }, }; diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts index 8e81b140d3a..0a2a626c313 100644 --- a/packages/cli/src/acp-integration/schema.ts +++ b/packages/cli/src/acp-integration/schema.ts @@ -369,6 +369,8 @@ export const sessionUpdateMetaSchema = z.object({ toolName: z.string().optional().nullable(), parentToolCallId: z.string().optional().nullable(), subagentType: z.string().optional().nullable(), + /** Server-side timestamp (ms since epoch) for correct message ordering */ + timestamp: z.number().optional().nullable(), }); export type SessionUpdateMeta = z.infer; @@ -560,6 +562,7 @@ export const sessionUpdateSchema = z.union([ z.object({ content: contentBlockSchema, sessionUpdate: z.literal('user_message_chunk'), + _meta: sessionUpdateMetaSchema.optional().nullable(), }), z.object({ content: contentBlockSchema, diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts index ef750f5397e..9e8a5ddcccb 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.test.ts @@ -37,6 +37,8 @@ describe('HistoryReplayer', () => { replayer = new HistoryReplayer(mockContext); }); + const toEpochMs = (ts: string) => new Date(ts).getTime(); + const createUserRecord = (text: string): ChatRecord => ({ uuid: 'user-uuid', parentUuid: null, @@ -127,13 +129,15 @@ describe('HistoryReplayer', () => { describe('user message replay', () => { it('should emit user_message_chunk for user records', async () => { - const records = [createUserRecord('Hello, world!')]; + const record = createUserRecord('Hello, world!'); + const records = [record]; await replayer.replay(records); expect(sendUpdateSpy).toHaveBeenCalledWith({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'Hello, world!' }, + _meta: { timestamp: toEpochMs(record.timestamp) }, }); }); @@ -151,24 +155,28 @@ describe('HistoryReplayer', () => { describe('assistant message replay', () => { it('should emit agent_message_chunk for assistant records', async () => { - const records = [createAssistantRecord('I can help with that.')]; + const record = createAssistantRecord('I can help with that.'); + const records = [record]; await replayer.replay(records); expect(sendUpdateSpy).toHaveBeenCalledWith({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'I can help with that.' }, + _meta: { timestamp: toEpochMs(record.timestamp) }, }); }); it('should emit agent_thought_chunk for thought parts', async () => { - const records = [createAssistantRecord('Thinking about this...', true)]; + const record = createAssistantRecord('Thinking about this...', true); + const records = [record]; await replayer.replay(records); expect(sendUpdateSpy).toHaveBeenCalledWith({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Thinking about this...' }, + _meta: { timestamp: toEpochMs(record.timestamp) }, }); }); @@ -191,14 +199,17 @@ describe('HistoryReplayer', () => { expect(sendUpdateSpy.mock.calls[0][0]).toEqual({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'First part' }, + _meta: { timestamp: toEpochMs(record.timestamp) }, }); expect(sendUpdateSpy.mock.calls[1][0]).toEqual({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Second part' }, + _meta: { timestamp: toEpochMs(record.timestamp) }, }); expect(sendUpdateSpy.mock.calls[2][0]).toEqual({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Third part' }, + _meta: { timestamp: toEpochMs(record.timestamp) }, }); }); }); @@ -228,7 +239,10 @@ describe('HistoryReplayer', () => { status: 'in_progress', title: 'read_file', rawInput: { path: '/test.ts' }, - _meta: { toolName: 'read_file' }, + _meta: { + toolName: 'read_file', + timestamp: toEpochMs(record.timestamp), + }, }), ); }); @@ -262,9 +276,8 @@ describe('HistoryReplayer', () => { describe('tool result replay', () => { it('should emit tool_call_update for tool result records', async () => { - const records = [ - createToolResultRecord('read_file', 'File contents here'), - ]; + const record = createToolResultRecord('read_file', 'File contents here'); + const records = [record]; await replayer.replay(records); @@ -281,7 +294,10 @@ describe('HistoryReplayer', () => { ], // resultDisplay is included as rawOutput rawOutput: 'File contents here', - _meta: { toolName: 'read_file' }, + _meta: { + toolName: 'read_file', + timestamp: toEpochMs(record.timestamp), + }, }); }); @@ -441,6 +457,7 @@ describe('HistoryReplayer', () => { expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello!' }, + _meta: { timestamp: toEpochMs(record.timestamp) }, }); expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, { sessionUpdate: 'agent_message_chunk', diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.ts index 0ecbccb9b19..842ad66b991 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.ts @@ -47,13 +47,17 @@ export class HistoryReplayer { switch (record.type) { case 'user': if (record.message) { - await this.replayContent(record.message, 'user'); + await this.replayContent(record.message, 'user', record.timestamp); } break; case 'assistant': if (record.message) { - await this.replayContent(record.message, 'assistant'); + await this.replayContent( + record.message, + 'assistant', + record.timestamp, + ); } if (record.usageMetadata) { await this.replayUsageMetadata(record.usageMetadata); @@ -73,16 +77,26 @@ export class HistoryReplayer { /** * Replays content from a message (user or assistant). * Handles text parts, thought parts, and function calls. + * + * @param content - The content to replay + * @param role - The role (user or assistant) + * @param timestamp - Optional server-side timestamp from the JSONL record */ private async replayContent( content: Content, role: 'user' | 'assistant', + timestamp?: string, ): Promise { for (const part of content.parts ?? []) { // Text content if ('text' in part && part.text) { const isThought = (part as { thought?: boolean }).thought ?? false; - await this.messageEmitter.emitMessage(part.text, role, isThought); + await this.messageEmitter.emitMessage( + part.text, + role, + isThought, + timestamp, + ); } // Function call (tool start) @@ -95,6 +109,7 @@ export class HistoryReplayer { callId, args: part.functionCall.args as Record, status: 'in_progress', + timestamp, }); } } @@ -134,6 +149,7 @@ export class HistoryReplayer { // For TodoWriteTool fallback, try to extract args from the record // Note: args aren't stored in tool_result records by default args: undefined, + timestamp: record.timestamp, }); // Special handling: Task tool execution summary contains token usage diff --git a/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts b/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts index 0dbbc91c88c..b0b05e7e897 100644 --- a/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts @@ -14,6 +14,21 @@ import type * as acp from '../../acp.js'; export abstract class BaseEmitter { constructor(protected readonly ctx: SessionContext) {} + /** + * Converts an ISO timestamp string or epoch ms to epoch ms number. + * Returns undefined if the input is not a valid timestamp. + */ + protected static toEpochMs(ts?: string | number): number | undefined { + if (typeof ts === 'number') { + return Number.isFinite(ts) ? ts : undefined; + } + if (typeof ts === 'string') { + const ms = new Date(ts).getTime(); + return Number.isFinite(ms) ? ms : undefined; + } + return undefined; + } + /** * Sends a session update to the ACP client. */ diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts index edf943b21e0..a81520be375 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts @@ -18,31 +18,55 @@ import { BaseEmitter } from './BaseEmitter.js'; export class MessageEmitter extends BaseEmitter { /** * Emits a user message chunk. + * + * @param text - The user message text content + * @param timestamp - Optional server-side timestamp (ISO string or ms) for message ordering */ - async emitUserMessage(text: string): Promise { + async emitUserMessage( + text: string, + timestamp?: string | number, + ): Promise { + const epochMs = BaseEmitter.toEpochMs(timestamp); await this.sendUpdate({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text }, + ...(epochMs != null && { _meta: { timestamp: epochMs } }), }); } /** * Emits an agent thought chunk. + * + * @param text - The thought text content + * @param timestamp - Optional server-side timestamp (ISO string or ms) for message ordering */ - async emitAgentThought(text: string): Promise { + async emitAgentThought( + text: string, + timestamp?: string | number, + ): Promise { + const epochMs = BaseEmitter.toEpochMs(timestamp); await this.sendUpdate({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text }, + ...(epochMs != null && { _meta: { timestamp: epochMs } }), }); } /** * Emits an agent message chunk. + * + * @param text - The agent message text content + * @param timestamp - Optional server-side timestamp (ISO string or ms) for message ordering */ - async emitAgentMessage(text: string): Promise { + async emitAgentMessage( + text: string, + timestamp?: string | number, + ): Promise { + const epochMs = BaseEmitter.toEpochMs(timestamp); await this.sendUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text }, + ...(epochMs != null && { _meta: { timestamp: epochMs } }), }); } @@ -82,17 +106,19 @@ export class MessageEmitter extends BaseEmitter { * @param text - The message text content * @param role - Whether this is a user or assistant message * @param isThought - Whether this is an assistant thought (only applies to assistant role) + * @param timestamp - Optional server-side timestamp (ISO string or ms) for message ordering */ async emitMessage( text: string, role: 'user' | 'assistant', isThought: boolean = false, + timestamp?: string | number, ): Promise { if (role === 'user') { - return this.emitUserMessage(text); + return this.emitUserMessage(text, timestamp); } return isThought - ? this.emitAgentThought(text) - : this.emitAgentMessage(text); + ? this.emitAgentThought(text, timestamp) + : this.emitAgentMessage(text, timestamp); } } diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts index e925567a790..dc60e18a238 100644 --- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts @@ -69,6 +69,9 @@ export class ToolCallEmitter extends BaseEmitter { _meta: { toolName: params.toolName, ...params.subagentMeta, + ...(BaseEmitter.toEpochMs(params.timestamp) != null && { + timestamp: BaseEmitter.toEpochMs(params.timestamp), + }), }, }); @@ -128,6 +131,9 @@ export class ToolCallEmitter extends BaseEmitter { _meta: { toolName: params.toolName, ...params.subagentMeta, + ...(BaseEmitter.toEpochMs(params.timestamp) != null && { + timestamp: BaseEmitter.toEpochMs(params.timestamp), + }), }, }; diff --git a/packages/cli/src/acp-integration/session/types.ts b/packages/cli/src/acp-integration/session/types.ts index 64cd262aa91..7b82f6e962b 100644 --- a/packages/cli/src/acp-integration/session/types.ts +++ b/packages/cli/src/acp-integration/session/types.ts @@ -49,6 +49,8 @@ export interface ToolCallStartParams { status?: 'pending' | 'in_progress' | 'completed' | 'failed'; /** Optional subagent metadata */ subagentMeta?: SubagentMeta; + /** Server-side timestamp (ISO string or ms) for message ordering */ + timestamp?: string | number; } /** @@ -71,6 +73,8 @@ export interface ToolCallResultParams { args?: Record; /** Optional subagent metadata */ subagentMeta?: SubagentMeta; + /** Server-side timestamp (ISO string or ms) for message ordering */ + timestamp?: string | number; } /** diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index bb19c8fca9e..0944ee5b7d5 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -88,10 +88,18 @@ export class QwenAgentManager { ) { const update = ( data as unknown as { - update: { sessionUpdate: string; content?: { text?: string } }; + update: { + sessionUpdate: string; + content?: { text?: string }; + _meta?: { timestamp?: number }; + }; } ).update; const text = update?.content?.text || ''; + const timestamp = + typeof update?._meta?.timestamp === 'number' + ? update._meta.timestamp + : Date.now(); if (update?.sessionUpdate === 'user_message_chunk' && text) { console.log( '[QwenAgentManager] Rehydration: routing user message chunk', @@ -99,7 +107,7 @@ export class QwenAgentManager { this.callbacks.onMessage?.({ role: 'user', content: text, - timestamp: Date.now(), + timestamp, }); return; } @@ -110,7 +118,7 @@ export class QwenAgentManager { this.callbacks.onMessage?.({ role: 'assistant', content: text, - timestamp: Date.now(), + timestamp, }); return; } diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts index 1833919b805..2000003fde1 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts @@ -86,6 +86,9 @@ export class QwenSessionUpdateHandler { case 'tool_call': { // Handle new tool call if (this.callbacks.onToolCall && 'toolCallId' in update) { + const meta = update._meta as SessionUpdateMeta | undefined; + const timestamp = + typeof meta?.timestamp === 'number' ? meta.timestamp : undefined; this.callbacks.onToolCall({ toolCallId: update.toolCallId as string, kind: (update.kind as string) || undefined, @@ -98,6 +101,7 @@ export class QwenSessionUpdateHandler { locations: update.locations as | Array<{ path: string; line?: number | null }> | undefined, + ...(timestamp !== undefined && { timestamp }), }); } break; @@ -105,6 +109,9 @@ export class QwenSessionUpdateHandler { case 'tool_call_update': { if (this.callbacks.onToolCall && 'toolCallId' in update) { + const meta = update._meta as SessionUpdateMeta | undefined; + const timestamp = + typeof meta?.timestamp === 'number' ? meta.timestamp : undefined; this.callbacks.onToolCall({ toolCallId: update.toolCallId as string, kind: (update.kind as string) || undefined, @@ -117,6 +124,7 @@ export class QwenSessionUpdateHandler { locations: update.locations as | Array<{ path: string; line?: number | null }> | undefined, + ...(timestamp !== undefined && { timestamp }), }); } break; diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index 73939cf325c..14304a38648 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -59,6 +59,7 @@ export interface UsageMetadata { export interface SessionUpdateMeta { usage?: UsageMetadata | null; durationMs?: number | null; + timestamp?: number | null; } export type AcpMeta = Record; @@ -81,6 +82,7 @@ export interface UserMessageChunkUpdate extends BaseSessionUpdate { update: { sessionUpdate: 'user_message_chunk'; content: ContentBlock; + _meta?: SessionUpdateMeta; }; } @@ -131,6 +133,7 @@ export interface ToolCallUpdate extends BaseSessionUpdate { path: string; line?: number | null; }>; + _meta?: SessionUpdateMeta; }; } @@ -156,6 +159,7 @@ export interface ToolCallStatusUpdate extends BaseSessionUpdate { path: string; line?: number | null; }>; + _meta?: SessionUpdateMeta; }; } diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts index 80029a062c6..b92cb35e52f 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -30,6 +30,7 @@ export interface ToolCallUpdateData { rawInput?: unknown; content?: Array>; locations?: Array<{ path: string; line?: number | null }>; + timestamp?: number; } export interface UsageStatsPayload { @@ -92,4 +93,10 @@ export interface ToolCallUpdate { line?: number | null; }>; timestamp?: number; // Add timestamp field for message ordering + /** Server-side metadata including timestamp for correct ordering */ + _meta?: { + timestamp?: number; + toolName?: string; + [key: string]: unknown; + }; } diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index a1a4ceb0a7d..4c7987c91ed 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -761,7 +761,7 @@ export const App: React.FC = () => { const inProgressTools = inProgressToolCalls.map((toolCall) => ({ type: 'in-progress-tool-call' as const, data: toolCall, - timestamp: toolCall.timestamp || Date.now(), + timestamp: toolCall.timestamp ?? 0, })); // Completed tool calls @@ -770,7 +770,7 @@ export const App: React.FC = () => { .map((toolCall) => ({ type: 'completed-tool-call' as const, data: toolCall, - timestamp: toolCall.timestamp || Date.now(), + timestamp: toolCall.timestamp ?? 0, })); // Merge and sort by timestamp to ensure messages and tool calls are interleaved diff --git a/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts b/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts index 1b994afda56..d471cacc06a 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts @@ -17,6 +17,29 @@ export const useToolCalls = () => { new Map(), ); + /** + * Preserve insertion order for existing tool calls by keeping the current + * timestamp. Only assign a new timestamp for brand-new entries. + */ + const resolveTimestamp = ( + update: ToolCallUpdate, + existing?: ToolCallData, + ): number => { + if ( + typeof existing?.timestamp === 'number' && + Number.isFinite(existing.timestamp) + ) { + return existing.timestamp; + } + if ( + typeof update.timestamp === 'number' && + Number.isFinite(update.timestamp) + ) { + return update.timestamp; + } + return Date.now(); + }; + /** * Handle tool call update */ @@ -143,7 +166,7 @@ export const useToolCalls = () => { ...prev, content, // Override (do not append) status: update.status || prev.status, - timestamp: update.timestamp || Date.now(), + timestamp: resolveTimestamp(update, prev), }); return newMap; } @@ -159,7 +182,7 @@ export const useToolCalls = () => { rawInput: update.rawInput as string | object | undefined, content, locations: update.locations, - timestamp: update.timestamp || Date.now(), // Add timestamp + timestamp: resolveTimestamp(update), }); } else if (update.type === 'tool_call_update') { const updatedContent = update.content @@ -186,12 +209,7 @@ export const useToolCalls = () => { mergedContent = [...(existing.content || []), ...updatedContent]; } } - // If tool call has just completed/failed, bump timestamp to now for correct ordering - const isFinal = - update.status === 'completed' || update.status === 'failed'; - const nextTimestamp = isFinal - ? Date.now() - : update.timestamp || existing.timestamp || Date.now(); + const nextTimestamp = resolveTimestamp(update, existing); newMap.set(update.toolCallId, { ...existing, @@ -200,7 +218,7 @@ export const useToolCalls = () => { ...(update.status && { status: update.status }), content: mergedContent, ...(update.locations && { locations: update.locations }), - timestamp: nextTimestamp, // Update timestamp (use completion time when completed/failed) + timestamp: nextTimestamp, }); } else { newMap.set(update.toolCallId, { @@ -211,7 +229,7 @@ export const useToolCalls = () => { rawInput: update.rawInput as string | object | undefined, content: updatedContent, locations: update.locations, - timestamp: update.timestamp || Date.now(), // Add timestamp + timestamp: resolveTimestamp(update), }); } } diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 43375f5a608..f3575de1284 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -936,15 +936,12 @@ export const useWebViewMessages = ({ } case 'cancelStreaming': - // Handle cancel streaming request from webview + // Handle cancel streaming response from extension + // Note: The "Interrupted" message is already added by handleCancel in App.tsx + // to provide immediate UI feedback. We only need to ensure streaming states + // are properly cleaned up here. handlers.messageHandling.endStreaming(); handlers.messageHandling.clearWaitingForResponse(); - // Add interrupted message - handlers.messageHandling.addMessage({ - role: 'assistant', - content: 'Interrupted', - timestamp: Date.now(), - }); break; default: diff --git a/packages/webui/src/components/toolcalls/ReadToolCall.tsx b/packages/webui/src/components/toolcalls/ReadToolCall.tsx index de2c0e388cf..5e36c10e410 100644 --- a/packages/webui/src/components/toolcalls/ReadToolCall.tsx +++ b/packages/webui/src/components/toolcalls/ReadToolCall.tsx @@ -8,7 +8,7 @@ */ import type { FC } from 'react'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FileLink } from '../layout/FileLink.js'; import { groupContent, @@ -68,6 +68,7 @@ export const ReadToolCall: FC = ({ const { content, locations, toolCallId } = toolCall; const platform = usePlatform(); const openedDiffsRef = useRef>(new Map()); + const [isExpanded, setIsExpanded] = useState(false); // Group content by type; memoize to avoid new array identities on every render const { errors, diffs, textOutputs } = useMemo( @@ -216,9 +217,13 @@ export const ReadToolCall: FC = ({ ); } - // Success case: show which file was read + // Success case: show which file was read (with optional content) if (locations && locations.length > 0) { const path = locations[0].path; + const textContent = textOutputs.length > 0 ? textOutputs.join('\n') : ''; + const EXPAND_THRESHOLD = 300; + const isLongContent = textContent.length > EXPAND_THRESHOLD; + return ( = ({ ) : undefined } > - {null} + {textContent ? ( +
+
+
+                {textContent}
+              
+
+ {isLongContent && ( + + )} +
+ ) : null} +
+ ); + } + + /** + * Fallback case: ACP message has content but no locations field. + * This can happen when: + * 1. IDE companion missed the initial tool_call event (which contains locations) + * 2. The CLI sent tool_call_update without locations + * 3. External tools (like MCP tools) don't provide location metadata + * + * In these cases, we still render the content to avoid silently dropping the output. + */ + if (textOutputs.length > 0) { + const textContent = textOutputs.join('\n'); + const EXPAND_THRESHOLD = 300; + const isLongContent = textContent.length > EXPAND_THRESHOLD; + + return ( + +
+
+
+              {textContent}
+            
+
+ {isLongContent && ( + + )} +
); } - // No file info, don't show + // No file info and no content - nothing to display return null; }; diff --git a/packages/webui/src/components/toolcalls/SaveMemoryToolCall.tsx b/packages/webui/src/components/toolcalls/SaveMemoryToolCall.tsx index 17f869269a9..e931c77d53f 100644 --- a/packages/webui/src/components/toolcalls/SaveMemoryToolCall.tsx +++ b/packages/webui/src/components/toolcalls/SaveMemoryToolCall.tsx @@ -6,35 +6,30 @@ * SaveMemory tool call component - displays saved memory content */ -import { useState, type FC } from 'react'; -import { ToolCallContainer, CopyButton, groupContent } from './shared/index.js'; +import type { FC } from 'react'; +import { + ToolCallContainer, + groupContent, + mapToolStatusToContainerStatus, +} from './shared/index.js'; import type { BaseToolCallProps } from './shared/index.js'; -/** Threshold for showing expand/collapse toggle */ -const EXPAND_THRESHOLD = 300; - /** * SaveMemory tool call component - * Displays saved memory content in a card format similar to Bash tool + * Displays saved memory content in a simple text format */ export const SaveMemoryToolCall: FC = ({ toolCall, isFirst, isLast, }) => { - const [isExpanded, setIsExpanded] = useState(false); const { content } = toolCall; // Group content by type const { textOutputs, errors } = groupContent(content); // Determine container status - const containerStatus = - errors.length > 0 - ? 'error' - : toolCall.status === 'pending' || toolCall.status === 'in_progress' - ? 'loading' - : 'success'; + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); // Error case if (errors.length > 0) { @@ -45,19 +40,8 @@ export const SaveMemoryToolCall: FC = ({ isFirst={isFirst} isLast={isLast} > -
-
-
-
- Error -
-
-
-                  {errors.join('\n')}
-                
-
-
-
+
+ {errors.join('\n')}
); @@ -69,7 +53,6 @@ export const SaveMemoryToolCall: FC = ({ } const memoryContent = textOutputs.join('\n\n'); - const isLongContent = memoryContent.length > EXPAND_THRESHOLD; return ( = ({ isFirst={isFirst} isLast={isLast} > - {/* Card container */} -
-
- {/* Content row */} -
-
- Memory -
-
-
-                {memoryContent}
-              
-
- -
- - {/* Expand/Collapse toggle */} - {isLongContent && ( - - )} -
+
+ {memoryContent}
);