diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 1c5e1c7d4..b9661ff64 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -20,6 +20,12 @@ import type { LoadedSettings } from '../../config/settings.js'; import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js'; vi.mock('../../nonInteractiveCliCommands.js', () => ({ + ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE: [ + 'init', + 'summary', + 'compress', + 'bug', + ], getAvailableCommands: vi.fn(), handleSlashCommand: vi.fn(), })); @@ -51,7 +57,6 @@ describe('Session', () => { let switchModelSpy: ReturnType; let getAvailableCommandsSpy: ReturnType; let mockToolRegistry: { getTool: ReturnType }; - beforeEach(() => { currentModel = 'qwen3-code-plus'; currentAuthType = AuthType.USE_OPENAI; @@ -205,6 +210,10 @@ describe('Session', () => { expect(getAvailableCommandsSpy).toHaveBeenCalledWith( mockConfig, expect.any(AbortSignal), + [ + ...nonInteractiveCliCommands.ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE, + 'insight', + ], ); expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ sessionId: 'test-session-id', diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index c56fcc9d0..9fed475ec 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -73,6 +73,7 @@ import type { LoadedSettings } from '../../config/settings.js'; import { z } from 'zod'; import { normalizePartList } from '../../utils/nonInteractiveHelpers.js'; import { + ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE, handleSlashCommand, getAvailableCommands, type NonInteractiveSlashCommandResult, @@ -81,6 +82,11 @@ import { isSlashCommand } from '../../ui/utils/commandUtils.js'; import { parseAcpModelOption } from '../../utils/acpModelUtils.js'; import { classifyApiError } from '../../ui/hooks/useGeminiStream.js'; +const ACP_ALLOWED_COMMANDS = [ + ...ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE, + 'insight', +]; + // Import modular session components import type { ApprovalModeValue, @@ -324,12 +330,13 @@ export class Session implements SessionContext { let parts: Part[] | null; if (isSlashCommand(inputText)) { - // Handle slash command - uses default allowed commands (init, summary, compress) + // ACP supports the standard non-interactive built-ins plus /insight. const slashCommandResult = await handleSlashCommand( inputText, pendingSend, this.config, this.settings, + ACP_ALLOWED_COMMANDS, ); parts = await this.#processSlashCommandResult( @@ -965,6 +972,7 @@ export class Session implements SessionContext { const slashCommands = await getAvailableCommands( this.config, abortController.signal, + ACP_ALLOWED_COMMANDS, ); // Convert SlashCommand[] to AvailableCommand[] format for ACP protocol diff --git a/packages/cli/src/ui/commands/insightCommand.test.ts b/packages/cli/src/ui/commands/insightCommand.test.ts index 159dd1747..272ce9d73 100644 --- a/packages/cli/src/ui/commands/insightCommand.test.ts +++ b/packages/cli/src/ui/commands/insightCommand.test.ts @@ -7,7 +7,7 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import path from 'path'; import open from 'open'; -import { Storage } from '@qwen-code/qwen-code-core'; +import { parseInsightMessage, Storage } from '@qwen-code/qwen-code-core'; import { insightCommand } from './insightCommand.js'; import type { CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; @@ -63,4 +63,112 @@ describe('insightCommand', () => { expect.any(Function), ); }); + + it('streams ACP progress messages without waiting for generation to finish', async () => { + let resolveInsight: ((outputPath: string) => void) | null = null; + let progressCallback: + | ((stage: string, progress: number, detail?: string) => void) + | null = null; + + mockGenerateStaticInsight.mockImplementation( + async ( + _projectsDir: string, + onProgress: (stage: string, progress: number, detail?: string) => void, + ) => { + progressCallback = onProgress; + return await new Promise((resolve) => { + resolveInsight = resolve; + }); + }, + ); + + const acpContext = createMockCommandContext({ + executionMode: 'acp', + services: { + config: {} as CommandContext['services']['config'], + }, + ui: { + addItem: vi.fn(), + setPendingItem: vi.fn(), + setDebugMessage: vi.fn(), + }, + } as unknown as CommandContext); + + if (!insightCommand.action) { + throw new Error('insight command must have action'); + } + + const actionPromise = insightCommand.action(acpContext, ''); + const initialResult = await Promise.race([ + actionPromise, + new Promise<'pending'>((resolve) => { + setTimeout(() => resolve('pending'), 0); + }), + ]); + + expect(initialResult).not.toBe('pending'); + expect(initialResult).toMatchObject({ type: 'stream_messages' }); + + if (!initialResult || initialResult === 'pending') { + throw new Error('ACP insight result did not resolve immediately'); + } + + const result = initialResult; + if (result.type !== 'stream_messages') { + throw new Error('ACP insight result must be stream_messages'); + } + + const messagesPromise = (async () => { + const messages: Array<{ + messageType: 'info' | 'error'; + content: string; + }> = []; + for await (const message of result.messages) { + messages.push(message); + } + return messages; + })(); + + const emitProgress = progressCallback as + | ((stage: string, progress: number, detail?: string) => void) + | null; + if (emitProgress) { + emitProgress('Analyzing sessions', 42, '21/50'); + } + const finishInsight = resolveInsight as + | ((outputPath: string) => void) + | null; + if (finishInsight) { + finishInsight( + path.resolve('runtime-output', 'insights', 'insight-2026-03-05.html'), + ); + } + + const messages = await messagesPromise; + + expect(messages[0]).toEqual({ + messageType: 'info', + content: 'This may take a couple minutes. Sit tight!', + }); + expect(parseInsightMessage(messages[1].content)).toEqual({ + type: 'insight_progress', + stage: 'Starting insight generation...', + progress: 0, + detail: undefined, + }); + expect(parseInsightMessage(messages[2].content)).toEqual({ + type: 'insight_progress', + stage: 'Analyzing sessions', + progress: 42, + detail: '21/50', + }); + expect(parseInsightMessage(messages[3].content)).toEqual({ + type: 'insight_ready', + path: path.resolve( + 'runtime-output', + 'insights', + 'insight-2026-03-05.html', + ), + }); + }); }); diff --git a/packages/cli/src/ui/commands/insightCommand.ts b/packages/cli/src/ui/commands/insightCommand.ts index de28381b3..7bd8d113d 100644 --- a/packages/cli/src/ui/commands/insightCommand.ts +++ b/packages/cli/src/ui/commands/insightCommand.ts @@ -11,7 +11,12 @@ import type { HistoryItemInsightProgress } from '../types.js'; import { t } from '../../i18n/index.js'; import { join } from 'path'; import { StaticInsightGenerator } from '../../services/insight/generators/StaticInsightGenerator.js'; -import { createDebugLogger, Storage } from '@qwen-code/qwen-code-core'; +import { + createDebugLogger, + encodeInsightProgressMessage, + encodeInsightReadyMessage, + Storage, +} from '@qwen-code/qwen-code-core'; import open from 'open'; const logger = createDebugLogger('DataProcessor'); @@ -36,6 +41,104 @@ export const insightCommand: SlashCommand = { context.services.config, ); + if (context.executionMode === 'acp') { + const pendingMessages: Array<{ + messageType: 'info' | 'error'; + content: string; + }> = []; + let isComplete = false; + let resume: (() => void) | null = null; + + const flushResume = () => { + const resolve = resume; + if (!resolve) { + return; + } + resume = null; + resolve(); + }; + + const pushMessage = (message: { + messageType: 'info' | 'error'; + content: string; + }) => { + pendingMessages.push(message); + flushResume(); + }; + + const streamMessages = async function* (): AsyncGenerator< + { messageType: 'info' | 'error'; content: string }, + void, + unknown + > { + while (!isComplete || pendingMessages.length > 0) { + if (pendingMessages.length === 0) { + await new Promise((resolve) => { + resume = resolve; + }); + } + + while (pendingMessages.length > 0) { + const message = pendingMessages.shift(); + if (message) { + yield message; + } + } + } + }; + + void (async () => { + try { + pushMessage({ + messageType: 'info', + content: t('This may take a couple minutes. Sit tight!'), + }); + pushMessage({ + messageType: 'info', + content: encodeInsightProgressMessage( + t('Starting insight generation...'), + 0, + ), + }); + + const outputPath = await insightGenerator.generateStaticInsight( + projectsDir, + (stage, progress, detail) => { + pushMessage({ + messageType: 'info', + content: encodeInsightProgressMessage( + stage, + progress, + detail, + ), + }); + }, + ); + + pushMessage({ + messageType: 'info', + content: encodeInsightReadyMessage(outputPath), + }); + } catch (error) { + pushMessage({ + messageType: 'error', + content: t('Failed to generate insights: {{error}}', { + error: (error as Error).message, + }), + }); + logger.error('Insight generation error:', error); + } finally { + isComplete = true; + flushResume(); + } + })(); + + return { + type: 'stream_messages', + messages: streamMessages(), + }; + } + const updateProgress = ( stage: string, progress: number, @@ -60,16 +163,13 @@ export const insightCommand: SlashCommand = { Date.now(), ); - // Initial progress updateProgress(t('Starting insight generation...'), 0); - // Generate the static insight HTML file const outputPath = await insightGenerator.generateStaticInsight( projectsDir, updateProgress, ); - // Clear pending item context.ui.setPendingItem(null); context.ui.addItem( @@ -80,7 +180,6 @@ export const insightCommand: SlashCommand = { Date.now(), ); - // Open the file in the default browser try { await open(outputPath); @@ -111,8 +210,8 @@ export const insightCommand: SlashCommand = { } context.ui.setDebugMessage(t('Insights ready.')); + return; } catch (error) { - // Clear pending item on error context.ui.setPendingItem(null); context.ui.addItem( @@ -126,6 +225,7 @@ export const insightCommand: SlashCommand = { ); logger.error('Insight generation error:', error); + return; } }, }; diff --git a/packages/core/src/core/insightProtocol.ts b/packages/core/src/core/insightProtocol.ts new file mode 100644 index 000000000..e75314ef6 --- /dev/null +++ b/packages/core/src/core/insightProtocol.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface InsightProgressPayload { + insight_progress: { + stage: string; + progress: number; + detail?: string; + }; +} + +export interface InsightReadyPayload { + insight_ready: { + path: string; + }; +} + +export type ParsedInsightMessage = + | { + type: 'insight_progress'; + stage: string; + progress: number; + detail?: string; + } + | { + type: 'insight_ready'; + path: string; + }; + +export function encodeInsightProgressMessage( + stage: string, + progress: number, + detail?: string, +): string { + const payload: InsightProgressPayload = { + insight_progress: { stage, progress, detail }, + }; + return JSON.stringify(payload); +} + +export function encodeInsightReadyMessage(path: string): string { + const payload: InsightReadyPayload = { + insight_ready: { path }, + }; + return JSON.stringify(payload); +} + +export function parseInsightMessage( + message: string, +): ParsedInsightMessage | null { + try { + const parsed = JSON.parse(message) as { + insight_progress?: { + stage?: unknown; + progress?: unknown; + detail?: unknown; + }; + insight_ready?: { path?: unknown }; + }; + + if (parsed.insight_progress) { + const { stage, progress, detail } = parsed.insight_progress; + if (typeof stage === 'string' && typeof progress === 'number') { + return { + type: 'insight_progress', + stage, + progress, + detail: typeof detail === 'string' ? detail : undefined, + }; + } + } + + if (parsed.insight_ready) { + const { path } = parsed.insight_ready; + if (typeof path === 'string') { + return { type: 'insight_ready', path }; + } + } + } catch { + return null; + } + + return null; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cb00d273e..2f7aa2a62 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -58,6 +58,7 @@ export * from './core/coreToolScheduler.js'; export * from './core/permission-helpers.js'; export * from './core/geminiChat.js'; export * from './core/geminiRequest.js'; +export * from './core/insightProtocol.js'; export * from './core/logger.js'; export * from './core/nonInteractiveToolExecutor.js'; export * from './core/prompts.js'; diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index bc0875ae5..9e2d6f6a9 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -32,6 +32,7 @@ import type { import type { AuthenticateUpdateNotification, AskUserQuestionRequest, + SlashCommandNotification, } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { ChildProcess, SpawnOptions } from 'child_process'; @@ -65,6 +66,8 @@ export class AcpConnection { }); onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void = () => {}; + onSlashCommandNotification: (data: SlashCommandNotification) => void = + () => {}; onEndTurn: (reason?: string) => void = () => {}; /** Invoked when the child process exits (expected or unexpected). */ onDisconnected: (code: number | null, signal: string | null) => void = @@ -344,6 +347,10 @@ export class AcpConnection { this.onAuthenticateUpdate( params as unknown as AuthenticateUpdateNotification, ); + } else if (method === '_qwencode/slash_command') { + this.onSlashCommandNotification( + params as unknown as SlashCommandNotification, + ); } else { console.warn(`[ACP] Unhandled extension notification: ${method}`); } diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 122e6d47b..72d2b4b03 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -14,6 +14,7 @@ import type { import type { AuthenticateUpdateNotification, AskUserQuestionRequest, + SlashCommandNotification, } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js'; @@ -271,6 +272,12 @@ export class QwenAgentManager { } }; + this.connection.onSlashCommandNotification = ( + data: SlashCommandNotification, + ) => { + this.callbacks.onSlashCommandNotification?.(data); + }; + // Initialize callback to surface available modes and current mode to UI this.connection.onInitialized = (init: unknown) => { try { @@ -1453,6 +1460,13 @@ export class QwenAgentManager { this.sessionUpdateHandler.updateCallbacks(this.callbacks); } + onSlashCommandNotification( + callback: (event: SlashCommandNotification) => void, + ): void { + this.callbacks.onSlashCommandNotification = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + /** * Register callback for unexpected process disconnection */ diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index c1e20b5f9..a2c685e18 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -24,6 +24,13 @@ export interface AuthenticateUpdateNotification { }; } +export interface SlashCommandNotification { + sessionId: string; + command: string; + messageType: 'info' | 'error'; + message: string; +} + export interface SessionUpdateMeta { usage?: Usage | null; durationMs?: number | null; diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts index 15f4b63db..81acd7c92 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -8,7 +8,10 @@ import type { AvailableCommand, RequestPermissionRequest, } from '@agentclientprotocol/sdk'; -import type { AskUserQuestionRequest } from './acpTypes.js'; +import type { + AskUserQuestionRequest, + SlashCommandNotification, +} from './acpTypes.js'; import type { ApprovalModeValue } from './approvalModeValueTypes.js'; export interface ChatMessage { @@ -80,6 +83,7 @@ export interface QwenAgentCallbacks { onAvailableCommands?: (commands: AvailableCommand[]) => void; onAvailableModels?: (models: ModelInfo[]) => void; onDisconnected?: (code: number | null, signal: string | null) => void; + onSlashCommandNotification?: (event: SlashCommandNotification) => void; } export interface ToolCallUpdate { diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index e6e11945b..8a95b8644 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -39,6 +39,7 @@ import { FileIcon, PermissionDrawer, AskUserQuestionDialog, + InsightProgressCard, ImageMessageRenderer, ImagePreview, // Layout components imported directly from webui @@ -199,6 +200,14 @@ export const App: React.FC = () => { AvailableCommand[] >([]); const [availableModels, setAvailableModels] = useState([]); + const [insightProgress, setInsightProgress] = useState<{ + stage: string; + progress: number; + detail?: string; + } | null>(null); + const [insightReportPath, setInsightReportPath] = useState( + null, + ); const [showModelSelector, setShowModelSelector] = useState(false); const [accountInfo, setAccountInfo] = useState(null); const messagesEndRef = useRef(null); @@ -440,6 +449,8 @@ export const App: React.FC = () => { setAccountInfo: (info) => { setAccountInfo(info); }, + setInsightReportPath, + setInsightProgress, }); // Auto-scroll handling: keep the view pinned to bottom when new content arrives, @@ -838,6 +849,17 @@ export const App: React.FC = () => { }); }, [vscode]); + const handleOpenInsightReport = useCallback(() => { + if (!insightReportPath) { + return; + } + vscode.postMessage({ + type: 'openInsightReport', + data: { path: insightReportPath }, + }); + }, [insightReportPath, vscode]); + + // Handle toggle edit mode (Default -> Auto-edit -> YOLO -> Default) const handleToggleEditMode = useCallback(() => { setEditMode((prev) => { const next: ApprovalModeValue = NEXT_APPROVAL_MODE[prev]; @@ -1010,6 +1032,32 @@ export const App: React.FC = () => { onFileClick={handleFileClick} /> + {insightProgress && ( + + )} + + {insightReportPath && ( + + )} + {/* Waiting message positioned fixed above the input form to avoid layout shifts */} {messageHandling.isWaitingForResponse && messageHandling.loadingMessage && ( diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx index 0fd14384e..5d68f4004 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.test.tsx @@ -35,6 +35,7 @@ function renderHookHarness(overrides?: { setUsageStats?: ReturnType; endStreaming?: ReturnType; clearWaitingForResponse?: ReturnType; + setInsightReportPath?: ReturnType; }) { const container = document.createElement('div'); document.body.appendChild(container); @@ -43,6 +44,7 @@ function renderHookHarness(overrides?: { const setUsageStats = overrides?.setUsageStats ?? vi.fn(); const endStreaming = overrides?.endStreaming ?? vi.fn(); const clearWaitingForResponse = overrides?.clearWaitingForResponse ?? vi.fn(); + const setInsightReportPath = overrides?.setInsightReportPath ?? vi.fn(); const handlers = { sessionManagement: { @@ -89,6 +91,7 @@ function renderHookHarness(overrides?: { setModelInfo: vi.fn(), setAvailableCommands: vi.fn(), setAvailableModels: vi.fn(), + setInsightReportPath, }; function Harness() { @@ -107,6 +110,7 @@ function renderHookHarness(overrides?: { setUsageStats, endStreaming, clearWaitingForResponse, + setInsightReportPath, }; } @@ -220,4 +224,50 @@ describe('useWebViewMessages', () => { expect(rendered.clearWaitingForResponse).toHaveBeenCalled(); }); + + it('clears the generic waiting state when insight progress starts', () => { + const rendered = renderHookHarness(); + root = rendered.root; + container = rendered.container; + + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'insightProgress', + data: { + stage: 'Analyzing sessions', + progress: 42, + detail: '21/50', + }, + }, + }), + ); + }); + + expect(rendered.clearWaitingForResponse).toHaveBeenCalled(); + }); + + it('stores the latest insight report path when the ready event arrives', () => { + const rendered = renderHookHarness(); + root = rendered.root; + container = rendered.container; + + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'insightReportReady', + data: { + path: '/tmp/insight-report.html', + }, + }, + }), + ); + }); + + expect(rendered.setInsightReportPath).toHaveBeenCalledWith( + '/tmp/insight-report.html', + ); + }); }); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 646f4ca52..0eca0d8eb 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -141,6 +141,12 @@ interface UseWebViewMessagesProps { error?: string; } | null, ) => void; + // Latest generated insight report path + setInsightReportPath?: (path: string | null) => void; + // Latest structured insight progress update + setInsightProgress?: ( + progress: { stage: string; progress: number; detail?: string } | null, + ) => void; } type ConversationResetHandlers = { @@ -214,6 +220,8 @@ export const useWebViewMessages = ({ setAvailableCommands, setAvailableModels, setAccountInfo, + setInsightReportPath, + setInsightProgress, }: UseWebViewMessagesProps) => { // VS Code API for posting messages back to the extension host const vscode = useVSCode(); @@ -231,6 +239,7 @@ export const useWebViewMessages = ({ // Track active long-running tool calls (execute/bash/command) so we can // keep the bottom "waiting" message visible until all of them complete. const activeExecToolCallsRef = useRef>(new Set()); + const activeInsightRunRef = useRef(false); const modelInfoRef = useRef(null); // Track the active requestId from the latest streamStart so we can // discard stale streamEnd events from cancelled/previous requests. @@ -251,6 +260,8 @@ export const useWebViewMessages = ({ setAvailableCommands, setAvailableModels, setAccountInfo, + setInsightReportPath, + setInsightProgress, }); // Track last "Updated Plan" snapshot toolcall to support merge/dedupe @@ -285,6 +296,29 @@ export const useWebViewMessages = ({ return true; }; + const clearInsightState = () => { + activeInsightRunRef.current = false; + handlersRef.current.setInsightProgress?.(null); + handlersRef.current.setInsightReportPath?.(null); + }; + + const setInsightProgressState = (progress: { + stage: string; + progress: number; + detail?: string; + }) => { + activeInsightRunRef.current = true; + handlersRef.current.setInsightReportPath?.(null); + handlersRef.current.messageHandling.clearWaitingForResponse(); + handlersRef.current.setInsightProgress?.(progress); + }; + + const setInsightReportReadyState = (path: string | null) => { + activeInsightRunRef.current = false; + handlersRef.current.setInsightProgress?.(null); + handlersRef.current.setInsightReportPath?.(path); + }; + // Update refs useEffect(() => { handlersRef.current = { @@ -302,6 +336,8 @@ export const useWebViewMessages = ({ setAvailableCommands, setAvailableModels, setAccountInfo, + setInsightReportPath, + setInsightProgress, }; }); @@ -502,6 +538,7 @@ export const useWebViewMessages = ({ case 'conversationLoaded': { const conversation = message.data as Conversation; + clearInsightState(); clearImageResolutions(); handlers.messageHandling.setMessages( materializeMessages(conversation.messages as WebViewMessageBase[]), @@ -611,6 +648,9 @@ export const useWebViewMessages = ({ if (FORCE_CLEAR_STREAM_END_REASONS.has(reason)) { // Clear active execution tool call tracking, reset state activeExecToolCallsRef.current.clear(); + if (activeInsightRunRef.current) { + clearInsightState(); + } // Clear waiting response state to ensure UI returns to normal handlers.messageHandling.clearWaitingForResponse(); break; @@ -635,6 +675,9 @@ export const useWebViewMessages = ({ handlers.messageHandling.endStreaming(); handlers.messageHandling.clearThinking(); activeExecToolCallsRef.current.clear(); + if (activeInsightRunRef.current) { + clearInsightState(); + } handlers.messageHandling.clearWaitingForResponse(); // Display error message to user so they know what went wrong const errorMessage = @@ -935,6 +978,7 @@ export const useWebViewMessages = ({ case 'qwenSessionSwitched': handlers.sessionManagement.setShowSessionSelector(false); + clearInsightState(); if (message.data.sessionId) { handlers.sessionManagement.setCurrentSessionId( message.data.sessionId as string, @@ -990,6 +1034,7 @@ export const useWebViewMessages = ({ break; case 'conversationCleared': + clearInsightState(); resetConversationState({ handlers: { ...handlers, @@ -1089,11 +1134,39 @@ export const useWebViewMessages = ({ ); break; } + + case 'insightProgress': { + const stage = message.data?.stage as string | undefined; + const progress = message.data?.progress as number | undefined; + const detail = message.data?.detail as string | undefined; + if (typeof stage === 'string' && typeof progress === 'number') { + setInsightProgressState({ + stage, + progress, + detail, + }); + } + break; + } + + case 'insightProgressCleared': { + clearInsightState(); + break; + } + + case 'insightReportReady': { + const path = message.data?.path as string | undefined; + setInsightReportReadyState(path ?? null); + break; + } case 'cancelStreaming': // 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. + if (activeInsightRunRef.current) { + clearInsightState(); + } handlers.messageHandling.endStreaming(); handlers.messageHandling.clearWaitingForResponse(); break; diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts index 3c87e078c..b9d52e4ad 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -7,14 +7,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { + availableCommandsCallbackRef, mockCreateImagePathResolver, mockGetGlobalTempDir, mockGetPanel, mockMessageHandlerInstances, mockOnDidChangeActiveTextEditor, mockOnDidChangeTextEditorSelection, + mockOpenExternal, + slashCommandNotificationCallbackRef, mockQwenAgentManagerInstances, } = vi.hoisted(() => ({ + availableCommandsCallbackRef: { + current: undefined as + | ((commands: Array<{ name: string; description?: string }>) => void) + | undefined, + }, mockCreateImagePathResolver: vi.fn(), mockGetGlobalTempDir: vi.fn(() => '/global-temp'), mockGetPanel: vi.fn<() => { webview: { postMessage: unknown } } | null>( @@ -28,17 +36,34 @@ const { }>, mockOnDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), mockOnDidChangeTextEditorSelection: vi.fn(() => ({ dispose: vi.fn() })), + mockOpenExternal: vi.fn(), + slashCommandNotificationCallbackRef: { + current: undefined as + | ((event: { + sessionId: string; + command: string; + messageType: 'info' | 'error'; + message: string; + }) => void) + | undefined, + }, mockQwenAgentManagerInstances: [] as Array<{ permissionRequestCallback?: (request: unknown) => Promise; cancelCurrentPrompt: ReturnType; }>, })); -vi.mock('@qwen-code/qwen-code-core', () => ({ - Storage: { - getGlobalTempDir: mockGetGlobalTempDir, - }, -})); +vi.mock('@qwen-code/qwen-code-core', async () => { + const actual = await vi.importActual< + typeof import('@qwen-code/qwen-code-core') + >('@qwen-code/qwen-code-core'); + return { + ...actual, + Storage: { + getGlobalTempDir: mockGetGlobalTempDir, + }, + }; +}); vi.mock('vscode', () => ({ Uri: { @@ -47,6 +72,9 @@ vi.mock('vscode', () => ({ })), file: vi.fn((filePath: string) => ({ fsPath: filePath })), }, + env: { + openExternal: mockOpenExternal, + }, window: { onDidChangeActiveTextEditor: mockOnDidChangeActiveTextEditor, onDidChangeTextEditorSelection: mockOnDidChangeTextEditorSelection, @@ -75,8 +103,28 @@ vi.mock('../../services/qwenAgentManager.js', () => ({ onUsageUpdate = vi.fn(); onModelInfo = vi.fn(); onModelChanged = vi.fn(); - onAvailableCommands = vi.fn(); + onAvailableCommands = vi.fn( + ( + callback: ( + commands: Array<{ name: string; description?: string }>, + ) => void, + ) => { + availableCommandsCallbackRef.current = callback; + }, + ); onAvailableModels = vi.fn(); + onSlashCommandNotification = vi.fn( + ( + callback: (event: { + sessionId: string; + command: string; + messageType: 'info' | 'error'; + message: string; + }) => void, + ) => { + slashCommandNotificationCallbackRef.current = callback; + }, + ); onEndTurn = vi.fn(); onToolCall = vi.fn(); onPlan = vi.fn(); @@ -179,12 +227,65 @@ import { MAX_PANEL_TITLE_LENGTH, } from '../utils/panelTitleUtils.js'; +type WebViewMessageHandler = (message: { + type: string; + data?: unknown; +}) => Promise; + +/** + * Create a mock webview + provider and attach them. + * If `captureMessageHandler` is true, the `onDidReceiveMessage` handler is + * captured and returned so the test can simulate messages from the webview. + */ +async function setupAttachedProvider(options?: { + captureMessageHandler?: boolean; +}) { + let messageHandler: WebViewMessageHandler | undefined; + + const postMessage = vi.fn(); + const webview = { + options: undefined as unknown, + html: '', + postMessage, + asWebviewUri: vi.fn((uri: { fsPath: string }) => ({ + toString: () => `webview:${uri.fsPath}`, + })), + onDidReceiveMessage: vi.fn((handler: WebViewMessageHandler) => { + if (options?.captureMessageHandler) { + messageHandler = handler; + } else { + void handler; + } + return { dispose: vi.fn() }; + }), + }; + + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + + await provider.attachToView( + { + webview, + visible: true, + onDidChangeVisibility: vi.fn(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), + } as never, + 'qwen-code.chatView.sidebar', + ); + + return { webview, postMessage, provider, messageHandler }; +} + describe('WebViewProvider.attachToView', () => { beforeEach(() => { vi.clearAllMocks(); mockMessageHandlerInstances.length = 0; mockQwenAgentManagerInstances.length = 0; mockGetPanel.mockReturnValue(null); + availableCommandsCallbackRef.current = undefined; + slashCommandNotificationCallbackRef.current = undefined; mockCreateImagePathResolver.mockReturnValue((paths: string[]) => paths.map((entry) => ({ path: entry, @@ -276,6 +377,151 @@ describe('WebViewProvider.attachToView', () => { }); }); + it('streams slash-command notifications into the attached webview', async () => { + const { postMessage } = await setupAttachedProvider(); + + slashCommandNotificationCallbackRef.current?.({ + sessionId: 'session-1', + command: '/summary', + messageType: 'info', + message: 'Generating project summary...', + }); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'streamChunk', + data: { + chunk: 'Generating project summary...\n', + }, + }); + }); + + it('re-sends cached available commands when the webview becomes ready', async () => { + const { postMessage, messageHandler } = await setupAttachedProvider({ + captureMessageHandler: true, + }); + + availableCommandsCallbackRef.current?.([ + { + name: 'insight', + description: 'Generate personalized insights', + }, + ]); + postMessage.mockClear(); + + await messageHandler?.({ + type: 'webviewReady', + data: {}, + }); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'availableCommands', + data: { + commands: [ + { + name: 'insight', + description: 'Generate personalized insights', + }, + ], + }, + }); + }); + + it('does not special-case plain insight slash notifications in the provider', async () => { + const { postMessage } = await setupAttachedProvider(); + + slashCommandNotificationCallbackRef.current?.({ + sessionId: 'session-1', + command: '/insight', + messageType: 'info', + message: 'Starting insight generation...', + }); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'streamChunk', + data: { + chunk: 'Starting insight generation...\n', + }, + }); + }); + + it('routes structured insight progress markers into the attached webview', async () => { + const { postMessage } = await setupAttachedProvider(); + + slashCommandNotificationCallbackRef.current?.({ + sessionId: 'session-1', + command: '/insight', + messageType: 'info', + message: + '{"insight_progress":{"stage":"Analyzing sessions","progress":42,"detail":"21/50"}}', + }); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'insightProgress', + data: { + stage: 'Analyzing sessions', + progress: 42, + detail: '21/50', + }, + }); + }); + + it('routes structured insight progress markers even when command text is normalized differently', async () => { + const { postMessage } = await setupAttachedProvider(); + + slashCommandNotificationCallbackRef.current?.({ + sessionId: 'session-1', + command: 'insight', + messageType: 'info', + message: + '{"insight_progress":{"stage":"Analyzing sessions","progress":42,"detail":"21/50"}}', + }); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'insightProgress', + data: { + stage: 'Analyzing sessions', + progress: 42, + detail: '21/50', + }, + }); + }); + + it('clears structured insight progress when the ready marker arrives', async () => { + const { webview } = await setupAttachedProvider(); + + slashCommandNotificationCallbackRef.current?.({ + sessionId: 'session-1', + command: '/insight', + messageType: 'info', + message: '{"insight_ready":{"path":"/tmp/insight-report.html"}}', + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(webview.postMessage).toHaveBeenCalledWith({ + type: 'insightReportReady', + data: { + path: '/tmp/insight-report.html', + }, + }); + }); + + it('opens the insight report in the browser when requested from the webview', async () => { + const { messageHandler } = await setupAttachedProvider({ + captureMessageHandler: true, + }); + + await messageHandler?.({ + type: 'openInsightReport', + data: { path: '/tmp/insight-report.html' }, + }); + + expect(mockOpenExternal).toHaveBeenCalledWith({ + fsPath: '/tmp/insight-report.html', + }); + }); + it('routes resolved image paths back to the requesting attached webview even when a panel exists', async () => { let messageHandler: | ((message: { type: string; data?: unknown }) => Promise) diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 2f84c89dc..2a2c20071 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -9,6 +9,7 @@ import { QwenAgentManager } from '../../services/qwenAgentManager.js'; import { ConversationStore } from '../../services/conversationStore.js'; import type { RequestPermissionRequest, + AvailableCommand, ModelInfo, } from '@agentclientprotocol/sdk'; import type { AskUserQuestionRequest } from '../../types/acpTypes.js'; @@ -25,6 +26,12 @@ import { createImagePathResolver } from '../utils/imageHandler.js'; import { type ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import { isAuthenticationRequiredError } from '../../utils/authErrors.js'; import { getErrorMessage } from '../../utils/errorMessage.js'; +import { parseInsightMessage } from '@qwen-code/qwen-code-core'; + +function isInsightCommand(command: string): boolean { + const [firstToken = ''] = command.trim().split(/\s+/, 1); + return firstToken.replace(/^\/+/, '') === 'insight'; +} export class WebViewProvider { private panelManager: PanelManager; @@ -46,6 +53,8 @@ export class WebViewProvider { // Track current ACP mode id to influence permission/diff behavior private currentModeId: ApprovalModeValue | null = null; private authState: boolean | null = null; + /** Cached available commands for re-sending on webview ready */ + private cachedAvailableCommands: AvailableCommand[] | null = null; /** Cached available models for re-sending on webview ready */ private cachedAvailableModels: ModelInfo[] | null = null; /** Model to apply once a new editor-tab session is initialized */ @@ -134,6 +143,50 @@ export class WebViewProvider { }); }); + this.agentManager.onSlashCommandNotification((event) => { + if (isInsightCommand(event.command) && event.messageType === 'error') { + this.sendMessageToWebView({ + type: 'insightProgressCleared', + data: {}, + }); + } + + // Try to parse as structured insight message + if (isInsightCommand(event.command) && event.messageType === 'info') { + const parsed = parseInsightMessage(event.message); + if (parsed?.type === 'insight_progress') { + this.sendMessageToWebView({ + type: 'insightProgress', + data: { + stage: parsed.stage, + progress: parsed.progress, + detail: parsed.detail, + }, + }); + return; + } + + if (parsed?.type === 'insight_ready') { + this.sendMessageToWebView({ + type: 'insightReportReady', + data: { + path: parsed.path, + }, + }); + return; + } + } + + const chunk = event.message.endsWith('\n') + ? event.message + : `${event.message}\n`; + this.messageHandler.appendStreamContent(chunk); + this.sendMessageToWebView({ + type: 'streamChunk', + data: { chunk }, + }); + }); + // Surface available modes and current mode (from ACP initialize) this.agentManager.onModeInfo((info) => { try { @@ -190,6 +243,7 @@ export class WebViewProvider { // Surface available commands (from ACP available_commands_update) this.agentManager.onAvailableCommands((commands) => { + this.cachedAvailableCommands = commands; this.sendMessageToWebView({ type: 'availableCommands', data: { commands }, @@ -463,6 +517,25 @@ export class WebViewProvider { }); } + private async openInsightReport(path: string): Promise { + await vscode.env.openExternal(vscode.Uri.file(path)); + } + + private async handleOpenInsightReportMessage(message: { + type: string; + data?: unknown; + }): Promise { + if (message.type !== 'openInsightReport') { + return false; + } + + const path = (message.data as { path?: unknown } | undefined)?.path; + if (typeof path === 'string' && path.length > 0) { + await this.openInsightReport(path); + } + return true; + } + /** * Attach the provider to a WebviewView (sidebar / panel / secondary sidebar). * Called from ChatWebviewViewProvider.resolveWebviewView when VS Code opens @@ -512,6 +585,9 @@ export class WebViewProvider { this.handleResolveImagePaths(message.data, webview); return; } + if (await this.handleOpenInsightReportMessage(message)) { + return; + } if (this.handleNewChatByContext(message)) { return; } @@ -671,6 +747,9 @@ export class WebViewProvider { this.handleResolveImagePaths(message.data, newPanel.webview); return; } + if (await this.handleOpenInsightReportMessage(message)) { + return; + } // Allow webview to request updating the VS Code tab title if (message.type === 'updatePanelTitle') { const title = String( @@ -1268,6 +1347,13 @@ export class WebViewProvider { }); } + if (this.cachedAvailableCommands) { + this.sendMessageToWebView({ + type: 'availableCommands', + data: { commands: this.cachedAvailableCommands }, + }); + } + // Send cached available models to webview if (this.cachedAvailableModels && this.cachedAvailableModels.length > 0) { console.log( @@ -1508,6 +1594,9 @@ export class WebViewProvider { this.handleResolveImagePaths(message.data, panel.webview); return; } + if (await this.handleOpenInsightReportMessage(message)) { + return; + } if (message.type === 'updatePanelTitle') { const title = String( (message.data as { title?: unknown } | undefined)?.title ?? '', diff --git a/packages/webui/src/components/messages/InsightProgressCard.tsx b/packages/webui/src/components/messages/InsightProgressCard.tsx new file mode 100644 index 000000000..f6456b00a --- /dev/null +++ b/packages/webui/src/components/messages/InsightProgressCard.tsx @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { FC } from 'react'; + +export interface InsightProgressCardProps { + stage: string; + progress: number; + detail?: string; +} + +const clamp = (value: number) => Math.max(0, Math.min(100, Math.round(value))); + +export const InsightProgressCard: FC = ({ + stage, + progress, + detail, +}) => { + const percent = clamp(progress); + + return ( +
+
+
+ {stage} +
+
+ {percent}% +
+ {detail ? ( +
+ {detail} +
+ ) : ( +
+ Processing your chat history… +
+ )} +
+
+ ); +}; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index d58711cfb..06acc35ac 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -78,6 +78,8 @@ export type { AssistantMessageProps, AssistantMessageStatus, } from './components/messages/Assistant/AssistantMessage'; +export { InsightProgressCard } from './components/messages/InsightProgressCard.js'; +export type { InsightProgressCardProps } from './components/messages/InsightProgressCard.js'; export { CollapsibleFileContent, parseContentWithFileReferences,