feat(vscode-ide-companion): support /insight command (#2593)

* feat(vscode-ide-companion): support /insight command

Add ACP support for /insight progress streaming and report opening in the VSCode companion.

Resolves #2023

* fix(cli): defer insight command runtime deps

* test(cli): cover acp slash command allowlist

* Revert "test(cli): cover acp slash command allowlist"

This reverts commit 3209274ab6.

* Revert "fix(cli): defer insight command runtime deps"

This reverts commit 3b08491e46.

* Reapply "fix(cli): defer insight command runtime deps"

This reverts commit 386c5c67d3.

* Reapply "test(cli): cover acp slash command allowlist"

This reverts commit e2716140dd.

* refactor(cli): simplify insight ACP integration

- Replace `formatAcpInsightProgress` with `encodeAcpInsightProgress` using JSON payload
- Move imports to top-level, no longer defer loading for non-ACP mode
- Remove `INSIGHT_READY_MARKER` parsing from Session.ts as it's now handled by WebViewProvider

* refactor: extract insight protocol markers to core package

Move INSIGHT_PROGRESS_MARKER and INSIGHT_READY_MARKER from cli and
vscode-ide-companion packages to @qwen-code/qwen-code-core for better
shareability and to avoid duplication.

Also extract ACP_ALLOWED_COMMANDS constant in Session.ts to improve
readability and maintainability.

* refactor(vscode-ide-companion): extract test helper to reduce webview mock duplication

Introduce `setupAttachedProvider()` helper in WebViewProvider.test.ts
to eliminate ~160 lines of repeated webview mock + provider setup code
across 5 insight-related test cases.

* feat(cli): 添加ACP执行模式到内置命令

当ACP启用时,将executionMode参数传递给所有内置命令,
使命令能够识别当前运行在ACP模式下并相应地调整行为。

test(cli): 为insight命令添加ACP进度消息流测试

新增测试验证insight命令在ACP模式下能够正确流式传输
进度消息,而不必等待生成完成。测试涵盖了从开始到完
成的整个进度更新过程。

refactor(core): 重构insight协议消息格式

将insight进度和就绪消息从基于标记字符串的格式
改为结构化的JSON格式,提供更好的类型安全和解析
可靠性。

feat(vscode-ide-companion): 支持新的insight消息协议

更新WebViewProvider以支持新的结构化insight消息协
议,能够正确解析和处理来自CLI的进度和就绪消息。
```

* fix(vscode-ide-companion/insight): streamline insight progress handling

Trim redundant CLI insight coverage around the ACP path.

Keep the VS Code insight progress flow aligned with normalized slash commands and the updated progress layout.

* fix(insight): restore slash commands after webview reload

Cache available commands in the VS Code provider so webview restoration still exposes /insight without a manual login.

Also remove the unused progress bar markup to keep the UI diff smaller.

* Update packages/webui/src/index.ts

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(webui): remove duplicate insight card export

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
易良 2026-04-20 10:02:18 +08:00 committed by GitHub
parent 41f71ab7e7
commit 7cded6e0df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 914 additions and 16 deletions

View file

@ -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<typeof vi.fn>;
let getAvailableCommandsSpy: ReturnType<typeof vi.fn>;
let mockToolRegistry: { getTool: ReturnType<typeof vi.fn> };
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',

View file

@ -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

View file

@ -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<string>((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',
),
});
});
});

View file

@ -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<void>((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;
}
},
};

View file

@ -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;
}

View file

@ -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';

View file

@ -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}`);
}

View file

@ -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
*/

View file

@ -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;

View file

@ -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 {

View file

@ -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<ModelInfo[]>([]);
const [insightProgress, setInsightProgress] = useState<{
stage: string;
progress: number;
detail?: string;
} | null>(null);
const [insightReportPath, setInsightReportPath] = useState<string | null>(
null,
);
const [showModelSelector, setShowModelSelector] = useState(false);
const [accountInfo, setAccountInfo] = useState<AccountInfo | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(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 && (
<InsightProgressCard
stage={insightProgress.stage}
progress={insightProgress.progress}
detail={insightProgress.detail}
/>
)}
{insightReportPath && (
<div className="px-[30px] py-2">
<div className="text-sm text-[var(--vscode-descriptionForeground)]">
Insight report generated at:
</div>
<a
href="#"
className="mt-1 inline-block break-all text-sm text-[var(--vscode-textLink-foreground)] underline decoration-[color-mix(in_srgb,var(--vscode-textLink-foreground)_55%,transparent)] underline-offset-2 hover:text-[var(--vscode-textLink-activeForeground)]"
onClick={(event) => {
event.preventDefault();
handleOpenInsightReport();
}}
>
{insightReportPath}
</a>
</div>
)}
{/* Waiting message positioned fixed above the input form to avoid layout shifts */}
{messageHandling.isWaitingForResponse &&
messageHandling.loadingMessage && (

View file

@ -35,6 +35,7 @@ function renderHookHarness(overrides?: {
setUsageStats?: ReturnType<typeof vi.fn>;
endStreaming?: ReturnType<typeof vi.fn>;
clearWaitingForResponse?: ReturnType<typeof vi.fn>;
setInsightReportPath?: ReturnType<typeof vi.fn>;
}) {
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',
);
});
});

View file

@ -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<Set<string>>(new Set());
const activeInsightRunRef = useRef(false);
const modelInfoRef = useRef<ModelInfo | null>(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;

View file

@ -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<string>;
cancelCurrentPrompt: ReturnType<typeof vi.fn>;
}>,
}));
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<void>;
/**
* 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<void>)

View file

@ -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<void> {
await vscode.env.openExternal(vscode.Uri.file(path));
}
private async handleOpenInsightReportMessage(message: {
type: string;
data?: unknown;
}): Promise<boolean> {
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 ?? '',

View file

@ -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<InsightProgressCardProps> = ({
stage,
progress,
detail,
}) => {
const percent = clamp(progress);
return (
<div className="w-full px-[30px] py-2">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1">
<div className="min-w-0 truncate text-sm leading-6 text-[var(--vscode-foreground)]">
{stage}
</div>
<div className="row-span-2 shrink-0 self-center text-xs leading-none tabular-nums text-[var(--vscode-descriptionForeground)]">
{percent}%
</div>
{detail ? (
<div className="min-w-0 truncate text-xs leading-5 text-[var(--vscode-descriptionForeground)]">
{detail}
</div>
) : (
<div className="text-xs leading-5 text-[var(--vscode-descriptionForeground)]">
Processing your chat history
</div>
)}
</div>
</div>
);
};

View file

@ -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,