mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-21 18:46:47 +00:00
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 commit3209274ab6. * Revert "fix(cli): defer insight command runtime deps" This reverts commit3b08491e46. * Reapply "fix(cli): defer insight command runtime deps" This reverts commit386c5c67d3. * Reapply "test(cli): cover acp slash command allowlist" This reverts commite2716140dd. * 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:
parent
41f71ab7e7
commit
7cded6e0df
17 changed files with 914 additions and 16 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
87
packages/core/src/core/insightProtocol.ts
Normal file
87
packages/core/src/core/insightProtocol.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>)
|
||||
|
|
|
|||
|
|
@ -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 ?? '',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue