From 89eb915723374bb13a01f4fa8b3e3079db8020aa Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 21 Mar 2026 01:31:11 +0800 Subject: [PATCH 01/13] feat(vscode): expose /skills as slash command with secondary picker Add a secondary completion picker for the /skills slash command in the VSCode IDE companion, allowing users to browse and select skills from a dropdown before sending. Changes: - CLI: add 'skills' to ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE whitelist - CLI: send available_skills_update via ACP with skill names/descriptions - Extension: handle available_skills_update in session update handler - Webview: implement secondary picker that triggers after selecting /skills - Webview: allow spaces in completion trigger for /skills sub-queries Closes #1562 Made-with: Cursor --- .../src/acp-integration/session/Session.ts | 18 +++++++ .../src/services/qwenAgentManager.ts | 10 ++++ .../src/services/qwenSessionUpdateHandler.ts | 22 +++++++++ .../src/types/chatTypes.ts | 3 ++ .../vscode-ide-companion/src/webview/App.tsx | 48 ++++++++++++++++++- .../src/webview/hooks/useCompletionTrigger.ts | 7 ++- .../src/webview/hooks/useWebViewMessages.ts | 21 ++++++++ .../webview/providers/WebViewProvider.test.ts | 1 + .../src/webview/providers/WebViewProvider.ts | 8 ++++ 9 files changed, 134 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 9c4d0f99921..b623a6a3a74 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -1007,6 +1007,24 @@ export class Session implements SessionContext { // Log error but don't fail session creation debugLogger.error('Error sending available commands update:', error); } + + // Send available skills list for secondary picker in IDE clients + try { + const skillManager = this.config.getSkillManager(); + if (skillManager) { + const skills = await skillManager.listSkills(); + const availableSkills = skills.map((s) => ({ + name: s.name, + description: s.description, + })); + await this.sendUpdate({ + sessionUpdate: 'available_skills_update', + availableSkills, + } as unknown as SessionUpdate); + } + } catch (error) { + debugLogger.error('Error sending available skills update:', error); + } } /** diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 426558eced5..05ebaa7566a 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -1478,6 +1478,16 @@ export class QwenAgentManager { this.sessionUpdateHandler.updateCallbacks(this.callbacks); } + /** + * Register callback for available skills updates (from ACP available_skills_update) + */ + onAvailableSkills( + callback: (skills: Array<{ name: string; description: string }>) => void, + ): void { + this.callbacks.onAvailableSkills = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + /** * Register callback for available models updates (from session/new response) */ diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts index 17ab8076894..71abc027333 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts @@ -210,6 +210,28 @@ export class QwenSessionUpdateHandler { break; } + case 'available_skills_update': { + try { + const skills = ( + update as unknown as { + availableSkills?: Array<{ + name: string; + description: string; + }>; + } + ).availableSkills; + if (skills && this.callbacks.onAvailableSkills) { + this.callbacks.onAvailableSkills(skills); + } + } catch (err) { + console.warn( + '[SessionUpdateHandler] Failed to handle available skills update', + err, + ); + } + break; + } + default: console.log('[QwenAgentManager] Unhandled session update type'); break; diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts index 81acd7c9219..f0691b7d0b1 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -81,6 +81,9 @@ export interface QwenAgentCallbacks { onModelInfo?: (info: ModelInfo) => void; onModelChanged?: (model: ModelInfo) => void; onAvailableCommands?: (commands: AvailableCommand[]) => void; + onAvailableSkills?: ( + skills: Array<{ name: string; description: string }>, + ) => void; onAvailableModels?: (models: ModelInfo[]) => void; onDisconnected?: (code: number | null, signal: string | null) => void; onSlashCommandNotification?: (event: SlashCommandNotification) => void; diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 8e6963c076d..00bccd4b5d0 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -199,6 +199,9 @@ export const App: React.FC = () => { const [availableCommands, setAvailableCommands] = useState< AvailableCommand[] >([]); + const [availableSkills, setAvailableSkills] = useState< + Array<{ name: string; description: string }> + >([]); const [availableModels, setAvailableModels] = useState([]); const [insightProgress, setInsightProgress] = useState<{ stage: string; @@ -266,6 +269,27 @@ export const App: React.FC = () => { return allItems; } else { + // Secondary picker: if query starts with "skills " (after /), + // show skill list instead of top-level commands + const skillsMatch = query.match(/^skills\s+(.*)/i); + if (skillsMatch && availableSkills.length > 0) { + const skillQuery = skillsMatch[1].toLowerCase(); + const skillItems: CompletionItem[] = availableSkills.map((skill) => ({ + id: `skill:${skill.name}`, + label: skill.name, + description: skill.description, + type: 'command' as const, + group: 'Skills', + value: `skills ${skill.name}`, + })); + return skillItems.filter( + (item) => + item.label.toLowerCase().includes(skillQuery) || + (item.description && + item.description.toLowerCase().includes(skillQuery)), + ); + } + // Handle slash commands with grouping // Model group - special items without / prefix const modelGroupItems: CompletionItem[] = [ @@ -325,7 +349,7 @@ export const App: React.FC = () => { ); } }, - [fileContext, availableCommands, modelInfo?.name], + [fileContext, availableCommands, availableSkills, modelInfo?.name], ); const completion = useCompletionTrigger(inputFieldRef, getCompletionItems); @@ -443,6 +467,9 @@ export const App: React.FC = () => { setAvailableCommands: (commands) => { setAvailableCommands(commands); }, + setAvailableSkills: (skills) => { + setAvailableSkills(skills); + }, setAvailableModels: (models) => { setAvailableModels(models); }, @@ -721,8 +748,11 @@ export const App: React.FC = () => { // Handle server-provided slash commands by sending them as messages. // Skip when fillOnly (Tab) — let the generic insertion path fill the // command text so the user can keep typing arguments. + // Special case: /skills always uses fill behavior (Enter = Tab) to + // allow the secondary skill picker to appear. const serverCmd = availableCommands.find((c) => c.name === itemId); - if (serverCmd && !fillOnly) { + const isSkillsCmd = itemId === 'skills'; + if (serverCmd && !fillOnly && !isSkillsCmd) { // Clear the trigger text since we're sending the command clearTriggerText(); // Send the slash command as a user message @@ -733,6 +763,20 @@ export const App: React.FC = () => { completion.closeCompletion(); return; } + + // Handle secondary skill selection — send `/skills ` with + // optional trailing user text + if (itemId.startsWith('skill:') && !fillOnly) { + clearTriggerText(); + const value = + typeof item.value === 'string' ? item.value : itemId.slice(6); + vscode.postMessage({ + type: 'sendMessage', + data: { text: `/${value}` }, + }); + completion.closeCompletion(); + return; + } } // If selecting a file, add @filename -> fullpath mapping diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index bfa85625bbc..fd82d1fa938 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -325,8 +325,11 @@ export function useCompletionTrigger( if (isValidTrigger) { const query = text.substring(triggerPos + 1, effectiveCursorPosition); - // Only show if query doesn't contain spaces (still typing the reference) - if (!query.includes(' ') && !query.includes('\n')) { + // For @ mentions: no spaces allowed (still typing the reference). + // For / commands: allow spaces when typing sub-command arguments + // (e.g., "/skills review" triggers the secondary skill picker). + const allowSpaces = triggerChar === '/' && /^skills\s/i.test(query); + if (!query.includes('\n') && (allowSpaces || !query.includes(' '))) { // Get precise cursor position for menu const cursorPos = getCursorPosition(); if (cursorPos) { diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 8ad6cf865b3..f8d7ab6f61c 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -130,6 +130,10 @@ interface UseWebViewMessagesProps { setModelInfo?: (info: ModelInfo | null) => void; // Available commands setter setAvailableCommands?: (commands: AvailableCommand[]) => void; + // Available skills setter + setAvailableSkills?: ( + skills: Array<{ name: string; description: string }>, + ) => void; // Available models setter setAvailableModels?: (models: ModelInfo[]) => void; // Account info setter (triggers dialog) @@ -219,6 +223,7 @@ export const useWebViewMessages = ({ setUsageStats, setModelInfo, setAvailableCommands, + setAvailableSkills, setAvailableModels, setAccountInfo, setInsightReportPath, @@ -259,6 +264,7 @@ export const useWebViewMessages = ({ setUsageStats, setModelInfo, setAvailableCommands, + setAvailableSkills, setAvailableModels, setAccountInfo, setInsightReportPath, @@ -335,6 +341,7 @@ export const useWebViewMessages = ({ setUsageStats, setModelInfo, setAvailableCommands, + setAvailableSkills, setAvailableModels, setAccountInfo, setInsightReportPath, @@ -397,6 +404,20 @@ export const useWebViewMessages = ({ break; } + case 'availableSkills': { + try { + const skills = message.data?.skills as + | Array<{ name: string; description: string }> + | undefined; + if (skills) { + handlers.setAvailableSkills?.(skills); + } + } catch (_error) { + // Ignore error when setting available skills + } + break; + } + case 'availableModels': { try { const models = message.data?.models as ModelInfo[] | undefined; 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 4412cdb6e67..d49b22fd5eb 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -161,6 +161,7 @@ vi.mock('../../services/qwenAgentManager.js', () => ({ availableCommandsCallbackRef.current = callback; }, ); + onAvailableSkills = vi.fn(); onAvailableModels = vi.fn(); onSlashCommandNotification = vi.fn( ( diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index bcae4dd88f7..1d8dbbbe6d8 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -335,6 +335,14 @@ export class WebViewProvider { }); }); + // Surface available skills (from ACP available_skills_update) + this.agentManager.onAvailableSkills((skills) => { + this.sendMessageToWebView({ + type: 'availableSkills', + data: { skills }, + }); + }); + // Surface available models (from session/new response) this.agentManager.onAvailableModels((models) => { console.log( From b88bec19a26711bffd58cdc6d7c59144f459aa67 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 24 Mar 2026 15:48:43 +0800 Subject: [PATCH 02/13] feat(vscode-ide-companion): embed skills in commands update metadata - Move available skills from separate session update to _meta field of available_commands_update for more efficient delivery - Simplify skill data to just skill names (string array) - Add skillsCompletion utility for secondary picker logic - Cache available skills in WebViewProvider for replay on webview ready - Update all related types and handlers to support the new structure Co-authored-by: Qwen-Coder --- .../acp-integration/session/Session.test.ts | 37 +++++++++++ .../src/acp-integration/session/Session.ts | 36 +++++----- .../src/services/qwenAgentManager.ts | 4 +- .../services/qwenSessionUpdateHandler.test.ts | 43 ++++++++++++ .../src/services/qwenSessionUpdateHandler.ts | 16 ++--- .../src/types/acpTypes.ts | 1 + .../src/types/chatTypes.ts | 4 +- .../vscode-ide-companion/src/webview/App.tsx | 60 ++++++++++------- .../src/webview/hooks/useCompletionTrigger.ts | 4 +- .../src/webview/hooks/useWebViewMessages.ts | 8 +-- .../webview/providers/WebViewProvider.test.ts | 66 +++++++++++++++++++ .../src/webview/providers/WebViewProvider.ts | 10 +++ .../webview/utils/skillsCompletion.test.ts | 56 ++++++++++++++++ .../src/webview/utils/skillsCompletion.ts | 44 +++++++++++++ 14 files changed, 327 insertions(+), 62 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/utils/skillsCompletion.test.ts create mode 100644 packages/vscode-ide-companion/src/webview/utils/skillsCompletion.ts diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index ea8a44dd6d9..f056e148abf 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -246,6 +246,43 @@ describe('Session', () => { }); }); + it('attaches available skills to available_commands_update metadata', async () => { + getAvailableCommandsSpy.mockResolvedValueOnce([ + { + name: 'init', + description: 'Initialize project context', + }, + ]); + mockConfig.getSkillManager = vi.fn().mockReturnValue({ + listSkills: vi + .fn() + .mockResolvedValue([ + { name: 'code-review-expert' }, + { name: 'verification-pack' }, + ]), + }); + + await session.sendAvailableCommandsUpdate(); + + expect(mockClient.sessionUpdate).toHaveBeenCalledTimes(1); + expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { + name: 'init', + description: 'Initialize project context', + input: null, + }, + ], + _meta: { + availableSkills: ['code-review-expert', 'verification-pack'], + }, + }, + }); + }); + it('swallows errors and does not throw', async () => { getAvailableCommandsSpy.mockRejectedValueOnce( new Error('Command discovery failed'), diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index b623a6a3a74..592045fab4b 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -997,9 +997,27 @@ export class Session implements SessionContext { }), ); + let availableSkills: string[] | undefined; + try { + const skillManager = this.config.getSkillManager(); + if (skillManager) { + const skills = await skillManager.listSkills(); + availableSkills = skills.map((skill) => skill.name); + } + } catch (error) { + debugLogger.error('Error loading available skills:', error); + } + const update: SessionUpdate = { sessionUpdate: 'available_commands_update', availableCommands, + ...(availableSkills + ? { + _meta: { + availableSkills, + }, + } + : {}), }; await this.sendUpdate(update); @@ -1007,24 +1025,6 @@ export class Session implements SessionContext { // Log error but don't fail session creation debugLogger.error('Error sending available commands update:', error); } - - // Send available skills list for secondary picker in IDE clients - try { - const skillManager = this.config.getSkillManager(); - if (skillManager) { - const skills = await skillManager.listSkills(); - const availableSkills = skills.map((s) => ({ - name: s.name, - description: s.description, - })); - await this.sendUpdate({ - sessionUpdate: 'available_skills_update', - availableSkills, - } as unknown as SessionUpdate); - } - } catch (error) { - debugLogger.error('Error sending available skills update:', error); - } } /** diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 05ebaa7566a..f0495670e76 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -1481,9 +1481,7 @@ export class QwenAgentManager { /** * Register callback for available skills updates (from ACP available_skills_update) */ - onAvailableSkills( - callback: (skills: Array<{ name: string; description: string }>) => void, - ): void { + onAvailableSkills(callback: (skills: string[]) => void): void { this.callbacks.onAvailableSkills = callback; this.sessionUpdateHandler.updateCallbacks(this.callbacks); } diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts index c0fff31d679..ef7024b19d2 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts @@ -352,6 +352,49 @@ describe('QwenSessionUpdateHandler', () => { }); }); + describe('available_skills_update handling', () => { + it('calls onAvailableSkills callback with skill names', () => { + mockCallbacks.onAvailableSkills = vi.fn(); + + const skillsUpdate = { + sessionId: 'test-session', + update: { + sessionUpdate: 'available_skills_update', + availableSkills: ['code-review-expert', 'verification-pack'], + }, + } as unknown as SessionNotification; + + handler.handleSessionUpdate(skillsUpdate); + + expect(mockCallbacks.onAvailableSkills).toHaveBeenCalledWith([ + 'code-review-expert', + 'verification-pack', + ]); + }); + + it('reads available skills from available_commands_update metadata', () => { + mockCallbacks.onAvailableSkills = vi.fn(); + + const commandsUpdate = { + sessionId: 'test-session', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [], + _meta: { + availableSkills: ['code-review-expert', 'verification-pack'], + }, + }, + } as unknown as SessionNotification; + + handler.handleSessionUpdate(commandsUpdate); + + expect(mockCallbacks.onAvailableSkills).toHaveBeenCalledWith([ + 'code-review-expert', + 'verification-pack', + ]); + }); + }); + describe('updateCallbacks', () => { it('updates mode callback and uses new one', () => { const newOnModeChanged = vi.fn(); diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts index 71abc027333..85b3b516037 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts @@ -201,6 +201,12 @@ export class QwenSessionUpdateHandler { if (commands && this.callbacks.onAvailableCommands) { this.callbacks.onAvailableCommands(commands); } + + const meta = (update as { _meta?: SessionUpdateMeta | null })._meta; + const availableSkills = meta?.availableSkills; + if (availableSkills && this.callbacks.onAvailableSkills) { + this.callbacks.onAvailableSkills(availableSkills); + } } catch (err) { console.warn( '[SessionUpdateHandler] Failed to handle available commands update', @@ -212,14 +218,8 @@ export class QwenSessionUpdateHandler { case 'available_skills_update': { try { - const skills = ( - update as unknown as { - availableSkills?: Array<{ - name: string; - description: string; - }>; - } - ).availableSkills; + const skills = (update as { availableSkills?: string[] }) + .availableSkills; if (skills && this.callbacks.onAvailableSkills) { this.callbacks.onAvailableSkills(skills); } diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index 8e3a322631d..1770c948746 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -39,6 +39,7 @@ export interface SessionUpdateMeta { usage?: Usage | null; durationMs?: number | null; timestamp?: number | null; + availableSkills?: string[] | null; } export { diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts index f0691b7d0b1..8bdaf640c15 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -81,9 +81,7 @@ export interface QwenAgentCallbacks { onModelInfo?: (info: ModelInfo) => void; onModelChanged?: (model: ModelInfo) => void; onAvailableCommands?: (commands: AvailableCommand[]) => void; - onAvailableSkills?: ( - skills: Array<{ name: string; description: string }>, - ) => void; + onAvailableSkills?: (skills: string[]) => void; onAvailableModels?: (models: ModelInfo[]) => void; onDisconnected?: (code: number | null, signal: string | null) => void; onSlashCommandNotification?: (event: SlashCommandNotification) => void; diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 00bccd4b5d0..a19d6b01dac 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -59,6 +59,11 @@ import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk'; import type { Question } from '../types/acpTypes.js'; import { useImagePaste, type WebViewImageMessage } from './hooks/useImage.js'; import { computeContextUsage } from './utils/contextUsage.js'; +import { + buildSkillCompletionItems, + isSkillsSecondaryQuery, + shouldOpenSkillsSecondaryPicker, +} from './utils/skillsCompletion.js'; /** * Memoized message list that only re-renders when messages or callbacks change, @@ -199,9 +204,7 @@ export const App: React.FC = () => { const [availableCommands, setAvailableCommands] = useState< AvailableCommand[] >([]); - const [availableSkills, setAvailableSkills] = useState< - Array<{ name: string; description: string }> - >([]); + const [availableSkills, setAvailableSkills] = useState([]); const [availableModels, setAvailableModels] = useState([]); const [insightProgress, setInsightProgress] = useState<{ stage: string; @@ -269,25 +272,8 @@ export const App: React.FC = () => { return allItems; } else { - // Secondary picker: if query starts with "skills " (after /), - // show skill list instead of top-level commands - const skillsMatch = query.match(/^skills\s+(.*)/i); - if (skillsMatch && availableSkills.length > 0) { - const skillQuery = skillsMatch[1].toLowerCase(); - const skillItems: CompletionItem[] = availableSkills.map((skill) => ({ - id: `skill:${skill.name}`, - label: skill.name, - description: skill.description, - type: 'command' as const, - group: 'Skills', - value: `skills ${skill.name}`, - })); - return skillItems.filter( - (item) => - item.label.toLowerCase().includes(skillQuery) || - (item.description && - item.description.toLowerCase().includes(skillQuery)), - ); + if (availableSkills.length > 0 && isSkillsSecondaryQuery(query)) { + return buildSkillCompletionItems(availableSkills, query); } // Handle slash commands with grouping @@ -388,6 +374,22 @@ export const App: React.FC = () => { completion.query, ]); + useEffect(() => { + if ( + completion.isOpen && + completion.triggerChar === '/' && + isSkillsSecondaryQuery(completion.query) + ) { + completion.refreshCompletion(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + availableSkills, + completion.isOpen, + completion.triggerChar, + completion.query, + ]); + const { attachedImages, handleRemoveImage, clearImages, handlePaste } = useImagePaste({ onError: (error) => { @@ -751,7 +753,7 @@ export const App: React.FC = () => { // Special case: /skills always uses fill behavior (Enter = Tab) to // allow the secondary skill picker to appear. const serverCmd = availableCommands.find((c) => c.name === itemId); - const isSkillsCmd = itemId === 'skills'; + const isSkillsCmd = shouldOpenSkillsSecondaryPicker(item); if (serverCmd && !fillOnly && !isSkillsCmd) { // Clear the trigger text since we're sending the command clearTriggerText(); @@ -858,6 +860,18 @@ export const App: React.FC = () => { newRange.collapse(false); sel?.removeAllRanges(); sel?.addRange(newRange); + + if (shouldOpenSkillsSecondaryPicker(item)) { + const rangeRect = newRange.getBoundingClientRect(); + const inputRect = inputElement.getBoundingClientRect(); + const position = + rangeRect.top > 0 || rangeRect.left > 0 + ? { top: rangeRect.top, left: rangeRect.left } + : { top: inputRect.top, left: inputRect.left }; + + void completion.openCompletion('/', `${insertValue} `, position); + return; + } } // Close the completion menu diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index fd82d1fa938..22dcbc26e10 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -7,6 +7,7 @@ import type { RefObject } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import type { CompletionItem } from '../../types/completionItemTypes.js'; +import { isSkillsSecondaryQuery } from '../utils/skillsCompletion.js'; interface CompletionTriggerState { isOpen: boolean; @@ -328,7 +329,8 @@ export function useCompletionTrigger( // For @ mentions: no spaces allowed (still typing the reference). // For / commands: allow spaces when typing sub-command arguments // (e.g., "/skills review" triggers the secondary skill picker). - const allowSpaces = triggerChar === '/' && /^skills\s/i.test(query); + const allowSpaces = + triggerChar === '/' && isSkillsSecondaryQuery(query); if (!query.includes('\n') && (allowSpaces || !query.includes(' '))) { // Get precise cursor position for menu const cursorPos = getCursorPosition(); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index f8d7ab6f61c..77aaa6a2a4e 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -131,9 +131,7 @@ interface UseWebViewMessagesProps { // Available commands setter setAvailableCommands?: (commands: AvailableCommand[]) => void; // Available skills setter - setAvailableSkills?: ( - skills: Array<{ name: string; description: string }>, - ) => void; + setAvailableSkills?: (skills: string[]) => void; // Available models setter setAvailableModels?: (models: ModelInfo[]) => void; // Account info setter (triggers dialog) @@ -406,9 +404,7 @@ export const useWebViewMessages = ({ case 'availableSkills': { try { - const skills = message.data?.skills as - | Array<{ name: string; description: string }> - | undefined; + const skills = message.data?.skills as string[] | undefined; if (skills) { handlers.setAvailableSkills?.(skills); } 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 d49b22fd5eb..611be296446 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -725,6 +725,72 @@ describe('WebViewProvider.attachToView', () => { }), }); }); + + it('replays available skills to the webview after webviewReady', async () => { + let messageHandler: + | ((message: { type: string; data?: unknown }) => Promise) + | 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: (message: { type: string; data?: unknown }) => Promise, + ) => { + messageHandler = 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', + ); + + const agentManager = ( + provider as unknown as { + agentManager: { + onAvailableSkills: ReturnType; + }; + } + ).agentManager; + const onAvailableSkills = agentManager.onAvailableSkills.mock + .calls[0]?.[0] as ((skills: string[]) => void) | undefined; + + expect(onAvailableSkills).toBeTypeOf('function'); + + const skills = ['code-review-expert']; + onAvailableSkills?.(skills); + + postMessage.mockClear(); + + await messageHandler?.({ + type: 'webviewReady', + data: {}, + }); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'availableSkills', + data: { skills }, + }); + }); }); describe('WebViewProvider settings sync', () => { diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 1d8dbbbe6d8..0f8e251aaf7 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -68,6 +68,8 @@ export class WebViewProvider { private authState: boolean | null = null; /** Cached available commands for re-sending on webview ready */ private cachedAvailableCommands: AvailableCommand[] | null = null; + /** Cached available skills for re-sending on webview ready */ + private cachedAvailableSkills: string[] | 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 */ @@ -337,6 +339,7 @@ export class WebViewProvider { // Surface available skills (from ACP available_skills_update) this.agentManager.onAvailableSkills((skills) => { + this.cachedAvailableSkills = skills; this.sendMessageToWebView({ type: 'availableSkills', data: { skills }, @@ -1623,6 +1626,13 @@ export class WebViewProvider { }); } + if (this.cachedAvailableSkills && this.cachedAvailableSkills.length > 0) { + this.sendMessageToWebView({ + type: 'availableSkills', + data: { skills: this.cachedAvailableSkills }, + }); + } + // Send cached available models to webview if (this.cachedAvailableModels && this.cachedAvailableModels.length > 0) { console.log( diff --git a/packages/vscode-ide-companion/src/webview/utils/skillsCompletion.test.ts b/packages/vscode-ide-companion/src/webview/utils/skillsCompletion.test.ts new file mode 100644 index 00000000000..0832e5bab97 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/skillsCompletion.test.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { CompletionItem } from '../../types/completionItemTypes.js'; +import { + buildSkillCompletionItems, + getSkillsSecondaryQuery, + shouldOpenSkillsSecondaryPicker, +} from './skillsCompletion.js'; + +describe('skillsCompletion', () => { + it('detects secondary /skills queries after a trailing space', () => { + expect(getSkillsSecondaryQuery('skills ')).toBe(''); + expect(getSkillsSecondaryQuery('skills review')).toBe('review'); + expect(getSkillsSecondaryQuery('skills')).toBeNull(); + expect(getSkillsSecondaryQuery('summary')).toBeNull(); + }); + + it('builds filtered completion items from skill names', () => { + const items = buildSkillCompletionItems( + ['code-review-expert', 'verification-pack'], + 'skills review', + ); + + expect(items).toEqual([ + { + id: 'skill:code-review-expert', + label: 'code-review-expert', + type: 'command', + group: 'Skills', + value: 'skills code-review-expert', + }, + ]); + }); + + it('marks /skills as requiring a secondary picker reopen', () => { + expect( + shouldOpenSkillsSecondaryPicker({ + id: 'skills', + label: '/skills', + type: 'command', + }), + ).toBe(true); + expect( + shouldOpenSkillsSecondaryPicker({ + id: 'summary', + label: '/summary', + type: 'command', + }), + ).toBe(false); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/utils/skillsCompletion.ts b/packages/vscode-ide-companion/src/webview/utils/skillsCompletion.ts new file mode 100644 index 00000000000..3f041ce54ec --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/skillsCompletion.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CompletionItem } from '../../types/completionItemTypes.js'; + +export function getSkillsSecondaryQuery(query: string): string | null { + const match = query.match(/^skills\s+(.*)$/i); + return match ? match[1] : null; +} + +export function isSkillsSecondaryQuery(query: string): boolean { + return getSkillsSecondaryQuery(query) !== null; +} + +export function buildSkillCompletionItems( + skills: string[], + query: string, +): CompletionItem[] { + const skillQuery = getSkillsSecondaryQuery(query); + if (skillQuery === null) { + return []; + } + + const normalizedQuery = skillQuery.toLowerCase(); + return skills + .map( + (skill) => + ({ + id: `skill:${skill}`, + label: skill, + type: 'command' as const, + group: 'Skills', + value: `skills ${skill}`, + }) satisfies CompletionItem, + ) + .filter((item) => item.label.toLowerCase().includes(normalizedQuery)); +} + +export function shouldOpenSkillsSecondaryPicker(item: CompletionItem): boolean { + return item.type === 'command' && item.id === 'skills'; +} From d9068b0ffb7f9ebb8d071faff53ddd4f575ff1dc Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 24 Mar 2026 17:03:51 +0800 Subject: [PATCH 03/13] refactor(vscode-ide-companion): simplify skills picker flow --- .../services/qwenSessionUpdateHandler.test.ts | 21 +--- .../src/services/qwenSessionUpdateHandler.ts | 16 --- .../vscode-ide-companion/src/webview/App.tsx | 107 +++++++++++------- .../src/webview/hooks/useCompletionTrigger.ts | 18 ++- .../webview/providers/WebViewProvider.test.ts | 66 ----------- .../src/webview/providers/WebViewProvider.ts | 12 +- .../webview/utils/skillsCompletion.test.ts | 56 --------- .../src/webview/utils/skillsCompletion.ts | 44 ------- 8 files changed, 84 insertions(+), 256 deletions(-) delete mode 100644 packages/vscode-ide-companion/src/webview/utils/skillsCompletion.test.ts delete mode 100644 packages/vscode-ide-companion/src/webview/utils/skillsCompletion.ts diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts index ef7024b19d2..accb21298d1 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts @@ -352,26 +352,7 @@ describe('QwenSessionUpdateHandler', () => { }); }); - describe('available_skills_update handling', () => { - it('calls onAvailableSkills callback with skill names', () => { - mockCallbacks.onAvailableSkills = vi.fn(); - - const skillsUpdate = { - sessionId: 'test-session', - update: { - sessionUpdate: 'available_skills_update', - availableSkills: ['code-review-expert', 'verification-pack'], - }, - } as unknown as SessionNotification; - - handler.handleSessionUpdate(skillsUpdate); - - expect(mockCallbacks.onAvailableSkills).toHaveBeenCalledWith([ - 'code-review-expert', - 'verification-pack', - ]); - }); - + describe('available skills handling', () => { it('reads available skills from available_commands_update metadata', () => { mockCallbacks.onAvailableSkills = vi.fn(); diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts index 85b3b516037..35b0f23a4db 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts @@ -216,22 +216,6 @@ export class QwenSessionUpdateHandler { break; } - case 'available_skills_update': { - try { - const skills = (update as { availableSkills?: string[] }) - .availableSkills; - if (skills && this.callbacks.onAvailableSkills) { - this.callbacks.onAvailableSkills(skills); - } - } catch (err) { - console.warn( - '[SessionUpdateHandler] Failed to handle available skills update', - err, - ); - } - break; - } - default: console.log('[QwenAgentManager] Unhandled session update type'); break; diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index a19d6b01dac..c9ec8edbc09 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -59,11 +59,16 @@ import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk'; import type { Question } from '../types/acpTypes.js'; import { useImagePaste, type WebViewImageMessage } from './hooks/useImage.js'; import { computeContextUsage } from './utils/contextUsage.js'; -import { - buildSkillCompletionItems, - isSkillsSecondaryQuery, - shouldOpenSkillsSecondaryPicker, -} from './utils/skillsCompletion.js'; + +const SKILL_ITEM_ID_PREFIX = 'skill:'; + +function isSkillsSecondaryQuery(query: string): boolean { + return /^skills\s+/i.test(query); +} + +function shouldOpenSkillsSecondaryPicker(item: CompletionItem): boolean { + return item.type === 'command' && item.id === 'skills'; +} /** * Memoized message list that only re-renders when messages or callbacks change, @@ -273,7 +278,19 @@ export const App: React.FC = () => { return allItems; } else { if (availableSkills.length > 0 && isSkillsSecondaryQuery(query)) { - return buildSkillCompletionItems(availableSkills, query); + const skillQuery = query.replace(/^skills\s+/i, '').toLowerCase(); + return availableSkills + .map( + (skill) => + ({ + id: `${SKILL_ITEM_ID_PREFIX}${skill}`, + label: skill, + type: 'command' as const, + group: 'Skills', + value: `skills ${skill}`, + }) satisfies CompletionItem, + ) + .filter((item) => item.label.toLowerCase().includes(skillQuery)); } // Handle slash commands with grouping @@ -339,6 +356,15 @@ export const App: React.FC = () => { ); const completion = useCompletionTrigger(inputFieldRef, getCompletionItems); + const { + isOpen: completionIsOpen, + triggerChar: completionTriggerChar, + query: completionQuery, + items: completionItems, + closeCompletion, + openCompletion, + refreshCompletion, + } = completion; const contextUsage = useMemo( () => computeContextUsage(usageStats, modelInfo), @@ -361,33 +387,32 @@ export const App: React.FC = () => { // Note: Avoid depending on the entire `completion` object here, since its identity // changes on every render which would retrigger this effect and can cause a refresh loop. useEffect(() => { - if (completion.isOpen && completion.triggerChar === '@') { + if (completionIsOpen && completionTriggerChar === '@') { // Only refresh items; do not change other completion state to avoid re-renders loops - completion.refreshCompletion(); + refreshCompletion(); } - // Only re-run when the actual data source changes, not on every render - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ workspaceFilesSignature, - completion.isOpen, - completion.triggerChar, - completion.query, + completionIsOpen, + completionTriggerChar, + completionQuery, + refreshCompletion, ]); useEffect(() => { if ( - completion.isOpen && - completion.triggerChar === '/' && - isSkillsSecondaryQuery(completion.query) + completionIsOpen && + completionTriggerChar === '/' && + isSkillsSecondaryQuery(completionQuery) ) { - completion.refreshCompletion(); + refreshCompletion(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ availableSkills, - completion.isOpen, - completion.triggerChar, - completion.query, + completionIsOpen, + completionTriggerChar, + completionQuery, + refreshCompletion, ]); const { attachedImages, handleRemoveImage, clearImages, handlePaste } = @@ -662,7 +687,7 @@ export const App: React.FC = () => { // Ignore info items (placeholders like "Searching files…") if (item.type === 'info') { - completion.closeCompletion(); + closeCompletion(); return; } @@ -729,21 +754,21 @@ export const App: React.FC = () => { if (itemId === 'auth') { clearTriggerText(); vscode.postMessage({ type: 'auth', data: {} }); - completion.closeCompletion(); + closeCompletion(); return; } if (itemId === 'account') { clearTriggerText(); vscode.postMessage({ type: 'getAccountInfo', data: {} }); - completion.closeCompletion(); + closeCompletion(); return; } if (itemId === 'model') { clearTriggerText(); setShowModelSelector(true); - completion.closeCompletion(); + closeCompletion(); return; } @@ -762,21 +787,23 @@ export const App: React.FC = () => { type: 'sendMessage', data: { text: `/${serverCmd.name}` }, }); - completion.closeCompletion(); + closeCompletion(); return; } // Handle secondary skill selection — send `/skills ` with // optional trailing user text - if (itemId.startsWith('skill:') && !fillOnly) { + if (itemId.startsWith(SKILL_ITEM_ID_PREFIX) && !fillOnly) { clearTriggerText(); const value = - typeof item.value === 'string' ? item.value : itemId.slice(6); + typeof item.value === 'string' + ? item.value + : itemId.slice(SKILL_ITEM_ID_PREFIX.length); vscode.postMessage({ type: 'sendMessage', data: { text: `/${value}` }, }); - completion.closeCompletion(); + closeCompletion(); return; } } @@ -838,7 +865,7 @@ export const App: React.FC = () => { const atPos = textBeforeCursor.lastIndexOf('@'); // Only consider slash as trigger if we're in slash command mode const slashPos = - completion.triggerChar === '/' ? textBeforeCursor.lastIndexOf('/') : -1; + completionTriggerChar === '/' ? textBeforeCursor.lastIndexOf('/') : -1; const triggerPos = Math.max(atPos, slashPos); if (triggerPos >= 0) { @@ -869,21 +896,23 @@ export const App: React.FC = () => { ? { top: rangeRect.top, left: rangeRect.left } : { top: inputRect.top, left: inputRect.left }; - void completion.openCompletion('/', `${insertValue} `, position); + void openCompletion('/', `${insertValue} `, position); return; } } // Close the completion menu - completion.closeCompletion(); + closeCompletion(); }, [ - completion, + availableCommands, + closeCompletion, + completionTriggerChar, + fileContext, inputFieldRef, + openCompletion, setInputText, - fileContext, vscode, - availableCommands, ], ); @@ -1198,16 +1227,16 @@ export const App: React.FC = () => { position = { top: inputRect.top, left: inputRect.left }; } - await completion.openCompletion('/', '', position); + await openCompletion('/', '', position); } }} onAttachContext={handleAttachContextClick} onPaste={handlePaste} - completionIsOpen={completion.isOpen} - completionItems={completion.items} + completionIsOpen={completionIsOpen} + completionItems={completionItems} onCompletionSelect={handleCompletionSelect} onCompletionFill={(item) => handleCompletionSelect(item, true)} - onCompletionClose={completion.closeCompletion} + onCompletionClose={closeCompletion} canSubmit={canSubmit} extraContent={ attachedImages.length > 0 ? ( diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index 22dcbc26e10..b19cc321841 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -7,7 +7,6 @@ import type { RefObject } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import type { CompletionItem } from '../../types/completionItemTypes.js'; -import { isSkillsSecondaryQuery } from '../utils/skillsCompletion.js'; interface CompletionTriggerState { isOpen: boolean; @@ -28,6 +27,8 @@ export function useCompletionTrigger( query: string, ) => Promise, ) { + const isSkillsSecondaryQuery = (query: string) => /^skills\s+/i.test(query); + // Show immediate loading and provide a timeout fallback for slow sources const LOADING_ITEM = useMemo( () => ({ @@ -55,12 +56,17 @@ export function useCompletionTrigger( position: { top: 0, left: 0 }, items: [], }); + const stateRef = useRef(state); // Timer for loading timeout const timeoutRef = useRef | null>(null); // Track request order so slower responses can't overwrite newer completions. const requestIdRef = useRef(0); + useEffect(() => { + stateRef.current = state; + }, [state]); + const closeCompletion = useCallback(() => { // Clear pending timeout if (timeoutRef.current) { @@ -180,12 +186,16 @@ export function useCompletionTrigger( }; const refreshCompletion = useCallback(async () => { - if (!state.isOpen || !state.triggerChar) { + const currentState = stateRef.current; + if (!currentState.isOpen || !currentState.triggerChar) { return; } const requestId = requestIdRef.current + 1; requestIdRef.current = requestId; - const items = await getCompletionItems(state.triggerChar, state.query); + const items = await getCompletionItems( + currentState.triggerChar, + currentState.query, + ); if (requestIdRef.current !== requestId) { return; } @@ -197,7 +207,7 @@ export function useCompletionTrigger( } return { ...prev, items }; }); - }, [state.isOpen, state.triggerChar, state.query, getCompletionItems]); + }, [getCompletionItems]); useEffect(() => { const inputElement = inputRef.current; 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 611be296446..d49b22fd5eb 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -725,72 +725,6 @@ describe('WebViewProvider.attachToView', () => { }), }); }); - - it('replays available skills to the webview after webviewReady', async () => { - let messageHandler: - | ((message: { type: string; data?: unknown }) => Promise) - | 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: (message: { type: string; data?: unknown }) => Promise, - ) => { - messageHandler = 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', - ); - - const agentManager = ( - provider as unknown as { - agentManager: { - onAvailableSkills: ReturnType; - }; - } - ).agentManager; - const onAvailableSkills = agentManager.onAvailableSkills.mock - .calls[0]?.[0] as ((skills: string[]) => void) | undefined; - - expect(onAvailableSkills).toBeTypeOf('function'); - - const skills = ['code-review-expert']; - onAvailableSkills?.(skills); - - postMessage.mockClear(); - - await messageHandler?.({ - type: 'webviewReady', - data: {}, - }); - - expect(postMessage).toHaveBeenCalledWith({ - type: 'availableSkills', - data: { skills }, - }); - }); }); describe('WebViewProvider settings sync', () => { diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 0f8e251aaf7..7b1b962ef02 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -68,8 +68,6 @@ export class WebViewProvider { private authState: boolean | null = null; /** Cached available commands for re-sending on webview ready */ private cachedAvailableCommands: AvailableCommand[] | null = null; - /** Cached available skills for re-sending on webview ready */ - private cachedAvailableSkills: string[] | 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 */ @@ -337,9 +335,8 @@ export class WebViewProvider { }); }); - // Surface available skills (from ACP available_skills_update) + // Surface available skills for the /skills secondary picker this.agentManager.onAvailableSkills((skills) => { - this.cachedAvailableSkills = skills; this.sendMessageToWebView({ type: 'availableSkills', data: { skills }, @@ -1626,13 +1623,6 @@ export class WebViewProvider { }); } - if (this.cachedAvailableSkills && this.cachedAvailableSkills.length > 0) { - this.sendMessageToWebView({ - type: 'availableSkills', - data: { skills: this.cachedAvailableSkills }, - }); - } - // Send cached available models to webview if (this.cachedAvailableModels && this.cachedAvailableModels.length > 0) { console.log( diff --git a/packages/vscode-ide-companion/src/webview/utils/skillsCompletion.test.ts b/packages/vscode-ide-companion/src/webview/utils/skillsCompletion.test.ts deleted file mode 100644 index 0832e5bab97..00000000000 --- a/packages/vscode-ide-companion/src/webview/utils/skillsCompletion.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it } from 'vitest'; -import type { CompletionItem } from '../../types/completionItemTypes.js'; -import { - buildSkillCompletionItems, - getSkillsSecondaryQuery, - shouldOpenSkillsSecondaryPicker, -} from './skillsCompletion.js'; - -describe('skillsCompletion', () => { - it('detects secondary /skills queries after a trailing space', () => { - expect(getSkillsSecondaryQuery('skills ')).toBe(''); - expect(getSkillsSecondaryQuery('skills review')).toBe('review'); - expect(getSkillsSecondaryQuery('skills')).toBeNull(); - expect(getSkillsSecondaryQuery('summary')).toBeNull(); - }); - - it('builds filtered completion items from skill names', () => { - const items = buildSkillCompletionItems( - ['code-review-expert', 'verification-pack'], - 'skills review', - ); - - expect(items).toEqual([ - { - id: 'skill:code-review-expert', - label: 'code-review-expert', - type: 'command', - group: 'Skills', - value: 'skills code-review-expert', - }, - ]); - }); - - it('marks /skills as requiring a secondary picker reopen', () => { - expect( - shouldOpenSkillsSecondaryPicker({ - id: 'skills', - label: '/skills', - type: 'command', - }), - ).toBe(true); - expect( - shouldOpenSkillsSecondaryPicker({ - id: 'summary', - label: '/summary', - type: 'command', - }), - ).toBe(false); - }); -}); diff --git a/packages/vscode-ide-companion/src/webview/utils/skillsCompletion.ts b/packages/vscode-ide-companion/src/webview/utils/skillsCompletion.ts deleted file mode 100644 index 3f041ce54ec..00000000000 --- a/packages/vscode-ide-companion/src/webview/utils/skillsCompletion.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { CompletionItem } from '../../types/completionItemTypes.js'; - -export function getSkillsSecondaryQuery(query: string): string | null { - const match = query.match(/^skills\s+(.*)$/i); - return match ? match[1] : null; -} - -export function isSkillsSecondaryQuery(query: string): boolean { - return getSkillsSecondaryQuery(query) !== null; -} - -export function buildSkillCompletionItems( - skills: string[], - query: string, -): CompletionItem[] { - const skillQuery = getSkillsSecondaryQuery(query); - if (skillQuery === null) { - return []; - } - - const normalizedQuery = skillQuery.toLowerCase(); - return skills - .map( - (skill) => - ({ - id: `skill:${skill}`, - label: skill, - type: 'command' as const, - group: 'Skills', - value: `skills ${skill}`, - }) satisfies CompletionItem, - ) - .filter((item) => item.label.toLowerCase().includes(normalizedQuery)); -} - -export function shouldOpenSkillsSecondaryPicker(item: CompletionItem): boolean { - return item.type === 'command' && item.id === 'skills'; -} From 1f563af356abd4b22854a33a3fe532be7d54e26c Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 30 Mar 2026 00:32:45 +0800 Subject: [PATCH 04/13] refactor(vscode-ide-companion): extract skills completion utils to shared module Move `isSkillsSecondaryQuery`, `shouldOpenSkillsSecondaryPicker`, and `SKILL_ITEM_ID_PREFIX` from App.tsx and useCompletionTrigger.ts into a shared `completionUtils.ts` file to eliminate duplication. --- .../vscode-ide-companion/src/webview/App.tsx | 15 +++----- .../src/webview/hooks/useCompletionTrigger.ts | 3 +- .../src/webview/utils/completionUtils.ts | 37 +++++++++++++++++++ 3 files changed, 43 insertions(+), 12 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/utils/completionUtils.ts diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index c9ec8edbc09..af09d1a2040 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -59,16 +59,11 @@ import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk'; import type { Question } from '../types/acpTypes.js'; import { useImagePaste, type WebViewImageMessage } from './hooks/useImage.js'; import { computeContextUsage } from './utils/contextUsage.js'; - -const SKILL_ITEM_ID_PREFIX = 'skill:'; - -function isSkillsSecondaryQuery(query: string): boolean { - return /^skills\s+/i.test(query); -} - -function shouldOpenSkillsSecondaryPicker(item: CompletionItem): boolean { - return item.type === 'command' && item.id === 'skills'; -} +import { + SKILL_ITEM_ID_PREFIX, + isSkillsSecondaryQuery, + shouldOpenSkillsSecondaryPicker, +} from './utils/completionUtils.js'; /** * Memoized message list that only re-renders when messages or callbacks change, diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index b19cc321841..602f35a4617 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -7,6 +7,7 @@ import type { RefObject } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import type { CompletionItem } from '../../types/completionItemTypes.js'; +import { isSkillsSecondaryQuery } from '../utils/completionUtils.js'; interface CompletionTriggerState { isOpen: boolean; @@ -27,8 +28,6 @@ export function useCompletionTrigger( query: string, ) => Promise, ) { - const isSkillsSecondaryQuery = (query: string) => /^skills\s+/i.test(query); - // Show immediate loading and provide a timeout fallback for slow sources const LOADING_ITEM = useMemo( () => ({ diff --git a/packages/vscode-ide-companion/src/webview/utils/completionUtils.ts b/packages/vscode-ide-companion/src/webview/utils/completionUtils.ts new file mode 100644 index 00000000000..b94a3c185b4 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/completionUtils.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Utility helpers for the /skills secondary completion picker. + */ + +import type { CompletionItem } from '../../types/completionItemTypes.js'; + +/** + * Prefix used to distinguish skill completion items from other commands. + * For example, a skill named "code-review" gets item id "skill:code-review". + */ +export const SKILL_ITEM_ID_PREFIX = 'skill:'; + +/** + * Check whether the current completion query is targeting the secondary + * skills picker (i.e. the user typed "/skills " followed by optional text). + * + * @param query - The text after the "/" trigger character + * @returns true when the query matches the "skills " pattern + */ +export function isSkillsSecondaryQuery(query: string): boolean { + return /^skills\s+/i.test(query); +} + +/** + * Determine whether selecting this completion item should open the + * secondary skills picker instead of sending the command immediately. + * + * @param item - The completion item the user selected + * @returns true when the item represents the /skills command + */ +export function shouldOpenSkillsSecondaryPicker(item: CompletionItem): boolean { + return item.type === 'command' && item.id === 'skills'; +} From fcd829d61697572bc73f48cd11bab4249eb37dda Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 5 Apr 2026 19:13:16 +0800 Subject: [PATCH 05/13] fix(vscode-ide-companion): restore skills picker state on reload Cache and replay available skills when the webview becomes ready again. Clear stale skills when commands metadata does not include availableSkills. --- .../services/qwenSessionUpdateHandler.test.ts | 16 +++++ .../src/services/qwenSessionUpdateHandler.ts | 5 +- .../webview/providers/WebViewProvider.test.ts | 66 +++++++++++++++++++ .../src/webview/providers/WebViewProvider.ts | 10 +++ 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts index accb21298d1..3ceb806ae11 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.test.ts @@ -374,6 +374,22 @@ describe('QwenSessionUpdateHandler', () => { 'verification-pack', ]); }); + + it('clears available skills when metadata is absent', () => { + mockCallbacks.onAvailableSkills = vi.fn(); + + const commandsUpdate = { + sessionId: 'test-session', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [], + }, + } as unknown as SessionNotification; + + handler.handleSessionUpdate(commandsUpdate); + + expect(mockCallbacks.onAvailableSkills).toHaveBeenCalledWith([]); + }); }); describe('updateCallbacks', () => { diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts index 35b0f23a4db..7630a1f7cea 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts @@ -203,9 +203,8 @@ export class QwenSessionUpdateHandler { } const meta = (update as { _meta?: SessionUpdateMeta | null })._meta; - const availableSkills = meta?.availableSkills; - if (availableSkills && this.callbacks.onAvailableSkills) { - this.callbacks.onAvailableSkills(availableSkills); + if (this.callbacks.onAvailableSkills) { + this.callbacks.onAvailableSkills(meta?.availableSkills ?? []); } } catch (err) { console.warn( 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 d49b22fd5eb..611be296446 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -725,6 +725,72 @@ describe('WebViewProvider.attachToView', () => { }), }); }); + + it('replays available skills to the webview after webviewReady', async () => { + let messageHandler: + | ((message: { type: string; data?: unknown }) => Promise) + | 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: (message: { type: string; data?: unknown }) => Promise, + ) => { + messageHandler = 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', + ); + + const agentManager = ( + provider as unknown as { + agentManager: { + onAvailableSkills: ReturnType; + }; + } + ).agentManager; + const onAvailableSkills = agentManager.onAvailableSkills.mock + .calls[0]?.[0] as ((skills: string[]) => void) | undefined; + + expect(onAvailableSkills).toBeTypeOf('function'); + + const skills = ['code-review-expert']; + onAvailableSkills?.(skills); + + postMessage.mockClear(); + + await messageHandler?.({ + type: 'webviewReady', + data: {}, + }); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'availableSkills', + data: { skills }, + }); + }); }); describe('WebViewProvider settings sync', () => { diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 7b1b962ef02..30c0e81caae 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -68,6 +68,8 @@ export class WebViewProvider { private authState: boolean | null = null; /** Cached available commands for re-sending on webview ready */ private cachedAvailableCommands: AvailableCommand[] | null = null; + /** Cached available skills for re-sending on webview ready */ + private cachedAvailableSkills: string[] | 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 */ @@ -337,6 +339,7 @@ export class WebViewProvider { // Surface available skills for the /skills secondary picker this.agentManager.onAvailableSkills((skills) => { + this.cachedAvailableSkills = skills; this.sendMessageToWebView({ type: 'availableSkills', data: { skills }, @@ -1623,6 +1626,13 @@ export class WebViewProvider { }); } + if (this.cachedAvailableSkills !== null) { + this.sendMessageToWebView({ + type: 'availableSkills', + data: { skills: this.cachedAvailableSkills }, + }); + } + // Send cached available models to webview if (this.cachedAvailableModels && this.cachedAvailableModels.length > 0) { console.log( From 8ff4e7840e01c6708a33d8827514549fd20d8d8c Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 5 Apr 2026 19:34:51 +0800 Subject: [PATCH 06/13] fix(vscode-ide-companion): replay slash commands after webview reload Cache available commands in the webview provider. Replay them on webviewReady so slash command state survives reloads. --- .../webview/providers/WebViewProvider.test.ts | 71 +++++++++++++++++++ .../src/webview/providers/WebViewProvider.ts | 8 +++ 2 files changed, 79 insertions(+) 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 611be296446..61cb7a4f711 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -791,6 +791,77 @@ describe('WebViewProvider.attachToView', () => { data: { skills }, }); }); + + it('replays available commands to the webview after webviewReady', async () => { + let messageHandler: + | ((message: { type: string; data?: unknown }) => Promise) + | 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: (message: { type: string; data?: unknown }) => Promise, + ) => { + messageHandler = 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', + ); + + const agentManager = ( + provider as unknown as { + agentManager: { + onAvailableCommands: ReturnType; + }; + } + ).agentManager; + const onAvailableCommands = agentManager.onAvailableCommands.mock + .calls[0]?.[0] as + | ((commands: Array<{ name: string; description: string }>) => void) + | undefined; + + expect(onAvailableCommands).toBeTypeOf('function'); + + const commands = [ + { name: 'skills', description: 'List available skills' }, + { name: 'compress', description: 'Compress the context' }, + ]; + onAvailableCommands?.(commands); + + postMessage.mockClear(); + + await messageHandler?.({ + type: 'webviewReady', + data: {}, + }); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'availableCommands', + data: { commands }, + }); + }); }); describe('WebViewProvider settings sync', () => { diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 30c0e81caae..d7a5a8497eb 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -17,6 +17,7 @@ import type { PermissionResponseMessage, AskUserQuestionResponseMessage, } from '../../types/webviewMessageTypes.js'; +import type { AvailableCommand } from '../../types/chatTypes.js'; import { PanelManager, getLocalResourceRoots } from './PanelManager.js'; import { MessageHandler } from './MessageHandler.js'; import { WebViewContent } from './WebViewContent.js'; @@ -1633,6 +1634,13 @@ export class WebViewProvider { }); } + if (this.cachedAvailableCommands !== null) { + this.sendMessageToWebView({ + type: 'availableCommands', + data: { commands: this.cachedAvailableCommands }, + }); + } + // Send cached available models to webview if (this.cachedAvailableModels && this.cachedAvailableModels.length > 0) { console.log( From 431f2adc695f8db7811746de03d079a5e63267d5 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 5 Apr 2026 23:26:45 +0800 Subject: [PATCH 07/13] fix(vscode-ide-companion): import AvailableCommand from ACP SDK --- .../src/webview/providers/WebViewProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index d7a5a8497eb..2b3b2e75655 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -11,13 +11,13 @@ import type { RequestPermissionRequest, AvailableCommand, ModelInfo, + AvailableCommand, } from '@agentclientprotocol/sdk'; import type { AskUserQuestionRequest } from '../../types/acpTypes.js'; import type { PermissionResponseMessage, AskUserQuestionResponseMessage, } from '../../types/webviewMessageTypes.js'; -import type { AvailableCommand } from '../../types/chatTypes.js'; import { PanelManager, getLocalResourceRoots } from './PanelManager.js'; import { MessageHandler } from './MessageHandler.js'; import { WebViewContent } from './WebViewContent.js'; From 9490327384071000412079cf958c04ca53ddcca2 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 19 Apr 2026 00:03:58 +0800 Subject: [PATCH 08/13] fix(vscode-ide-companion): fallback /skills to direct command --- .../vscode-ide-companion/src/webview/App.tsx | 8 ++- .../src/webview/utils/completionUtils.test.ts | 59 +++++++++++++++++++ .../src/webview/utils/completionUtils.ts | 15 ++++- 3 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/utils/completionUtils.test.ts diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index af09d1a2040..14b45bced2c 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -773,7 +773,10 @@ export const App: React.FC = () => { // Special case: /skills always uses fill behavior (Enter = Tab) to // allow the secondary skill picker to appear. const serverCmd = availableCommands.find((c) => c.name === itemId); - const isSkillsCmd = shouldOpenSkillsSecondaryPicker(item); + const isSkillsCmd = shouldOpenSkillsSecondaryPicker( + item, + availableSkills, + ); if (serverCmd && !fillOnly && !isSkillsCmd) { // Clear the trigger text since we're sending the command clearTriggerText(); @@ -883,7 +886,7 @@ export const App: React.FC = () => { sel?.removeAllRanges(); sel?.addRange(newRange); - if (shouldOpenSkillsSecondaryPicker(item)) { + if (shouldOpenSkillsSecondaryPicker(item, availableSkills)) { const rangeRect = newRange.getBoundingClientRect(); const inputRect = inputElement.getBoundingClientRect(); const position = @@ -901,6 +904,7 @@ export const App: React.FC = () => { }, [ availableCommands, + availableSkills, closeCompletion, completionTriggerChar, fileContext, diff --git a/packages/vscode-ide-companion/src/webview/utils/completionUtils.test.ts b/packages/vscode-ide-companion/src/webview/utils/completionUtils.test.ts new file mode 100644 index 00000000000..156b419bbf0 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/completionUtils.test.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { CompletionItem } from '../../types/completionItemTypes.js'; +import { + isSkillsSecondaryQuery, + shouldOpenSkillsSecondaryPicker, +} from './completionUtils.js'; + +const skillsCommandItem: CompletionItem = { + id: 'skills', + label: '/skills', + type: 'command', + value: 'skills', +}; + +describe('completionUtils', () => { + describe('isSkillsSecondaryQuery', () => { + it('matches /skills subqueries with trailing space', () => { + expect(isSkillsSecondaryQuery('skills ')).toBe(true); + expect(isSkillsSecondaryQuery('skills review')).toBe(true); + expect(isSkillsSecondaryQuery('skills code review')).toBe(true); + }); + + it('does not treat bare /skills as a secondary query', () => { + expect(isSkillsSecondaryQuery('skills')).toBe(false); + expect(isSkillsSecondaryQuery('compress')).toBe(false); + }); + }); + + describe('shouldOpenSkillsSecondaryPicker', () => { + it('opens the secondary picker only when skills are available', () => { + expect( + shouldOpenSkillsSecondaryPicker(skillsCommandItem, ['review', 'test']), + ).toBe(true); + expect(shouldOpenSkillsSecondaryPicker(skillsCommandItem, [])).toBe( + false, + ); + }); + + it('does not open for non-/skills commands', () => { + expect( + shouldOpenSkillsSecondaryPicker( + { + id: 'compress', + label: '/compress', + type: 'command', + value: 'compress', + }, + ['review'], + ), + ).toBe(false); + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/utils/completionUtils.ts b/packages/vscode-ide-companion/src/webview/utils/completionUtils.ts index b94a3c185b4..adcb387a603 100644 --- a/packages/vscode-ide-companion/src/webview/utils/completionUtils.ts +++ b/packages/vscode-ide-companion/src/webview/utils/completionUtils.ts @@ -30,8 +30,17 @@ export function isSkillsSecondaryQuery(query: string): boolean { * secondary skills picker instead of sending the command immediately. * * @param item - The completion item the user selected - * @returns true when the item represents the /skills command + * @param availableSkills - Skills advertised by the backend for the picker + * @returns true when the item represents the /skills command and there are + * available skills to show */ -export function shouldOpenSkillsSecondaryPicker(item: CompletionItem): boolean { - return item.type === 'command' && item.id === 'skills'; +export function shouldOpenSkillsSecondaryPicker( + item: CompletionItem, + availableSkills: string[], +): boolean { + return ( + item.type === 'command' && + item.id === 'skills' && + availableSkills.length > 0 + ); } From 8cbbff495dd7b1c5f5595e48dbd3a7780995baba Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 19 Apr 2026 23:38:09 +0800 Subject: [PATCH 09/13] test(vscode-ide-companion): cover skills secondary picker flow --- .../src/webview/App.test.tsx | 408 ++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 packages/vscode-ide-companion/src/webview/App.test.tsx diff --git a/packages/vscode-ide-companion/src/webview/App.test.tsx b/packages/vscode-ide-companion/src/webview/App.test.tsx new file mode 100644 index 00000000000..ca150148271 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/App.test.tsx @@ -0,0 +1,408 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import React, { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import type { CompletionItem } from '../types/completionItemTypes.js'; + +const { + mockPostMessage, + mockOpenCompletion, + mockCloseCompletion, +} = vi.hoisted(() => ({ + mockPostMessage: vi.fn(), + mockOpenCompletion: vi.fn().mockResolvedValue(undefined), + mockCloseCompletion: vi.fn(), +})); + +const slashSkillsItem: CompletionItem = { + id: 'skills', + label: '/skills', + type: 'command', + value: 'skills', +}; + +const secondarySkillItem: CompletionItem = { + id: 'skill:code-review', + label: 'code-review', + type: 'command', + value: 'skills code-review', +}; + +vi.mock('./hooks/useVSCode.js', () => ({ + useVSCode: () => ({ + postMessage: mockPostMessage, + }), +})); + +vi.mock('./hooks/session/useSessionManagement.js', () => ({ + useSessionManagement: () => ({ + showSessionSelector: false, + filteredSessions: [], + currentSessionId: 'session-1', + sessionSearchQuery: '', + setSessionSearchQuery: vi.fn(), + handleSwitchSession: vi.fn(), + setShowSessionSelector: vi.fn(), + hasMore: false, + isLoading: false, + handleLoadMoreSessions: vi.fn(), + handleLoadQwenSessions: vi.fn(), + handleNewQwenSession: vi.fn(), + currentSessionTitle: 'Session 1', + }), +})); + +vi.mock('./hooks/file/useFileContext.js', () => ({ + useFileContext: () => ({ + hasRequestedFiles: false, + workspaceFiles: [], + requestWorkspaceFiles: vi.fn(), + addFileReference: vi.fn(), + activeFileName: null, + activeSelection: null, + focusActiveEditor: vi.fn(), + }), +})); + +vi.mock('./hooks/message/useMessageHandling.js', () => ({ + useMessageHandling: () => ({ + messages: [], + isStreaming: false, + isWaitingForResponse: false, + loadingMessage: null, + addMessage: vi.fn(), + endStreaming: vi.fn(), + setWaitingForResponse: vi.fn(), + }), +})); + +vi.mock('./hooks/useToolCalls.js', () => ({ + useToolCalls: () => ({ + inProgressToolCalls: [], + completedToolCalls: [], + handleToolCallUpdate: vi.fn(), + clearToolCalls: vi.fn(), + }), +})); + +vi.mock('./hooks/useWebViewMessages.js', async () => { + const React = await import('react'); + return { + useWebViewMessages: ({ + setIsAuthenticated, + setAvailableCommands, + setAvailableSkills, + }: { + setIsAuthenticated: (value: boolean) => void; + setAvailableCommands: ( + value: Array<{ name: string; description?: string }>, + ) => void; + setAvailableSkills: (value: string[]) => void; + }) => { + React.useEffect(() => { + setIsAuthenticated(true); + setAvailableCommands([ + { name: 'skills', description: 'List available skills' }, + ]); + setAvailableSkills(['code-review']); + }, []); + }, + }; +}); + +vi.mock('./hooks/useMessageSubmit.js', () => ({ + useMessageSubmit: () => ({ + handleSubmit: vi.fn(), + }), + shouldSendMessage: () => true, +})); + +vi.mock('./hooks/useImage.js', () => ({ + useImagePaste: () => ({ + attachedImages: [], + handleRemoveImage: vi.fn(), + clearImages: vi.fn(), + handlePaste: vi.fn(), + }), +})); + +vi.mock('./hooks/useCompletionTrigger.js', () => ({ + useCompletionTrigger: () => ({ + isOpen: true, + triggerChar: '/', + query: 'skills ', + items: [slashSkillsItem, secondarySkillItem], + closeCompletion: mockCloseCompletion, + openCompletion: mockOpenCompletion, + refreshCompletion: vi.fn(), + }), +})); + +vi.mock('./utils/contextUsage.js', () => ({ + computeContextUsage: () => null, +})); + +vi.mock('./utils/utils.js', () => ({ + hasToolCallOutput: () => false, +})); + +vi.mock('./components/messages/toolcalls/ToolCall.js', () => ({ + ToolCall: () => null, +})); + +vi.mock('./components/layout/Onboarding.js', () => ({ + Onboarding: () => null, +})); + +vi.mock('./components/AccountInfoDialog.js', () => ({ + AccountInfoDialog: () => null, +})); + +vi.mock('@qwen-code/webui', () => ({ + AssistantMessage: () => null, + UserMessage: () => null, + ThinkingMessage: () => null, + WaitingMessage: () => null, + InterruptedMessage: () => null, + FileIcon: () => null, + PermissionDrawer: () => null, + AskUserQuestionDialog: () => null, + ImageMessageRenderer: () => null, + ImagePreview: () => null, + EmptyState: () => null, + ChatHeader: () => null, + SessionSelector: () => null, +})); + +vi.mock('./components/layout/InputForm.js', () => ({ + InputForm: ({ + inputText, + inputFieldRef, + onCompletionSelect, + onCompletionFill, + }: { + inputText: string; + inputFieldRef: React.RefObject; + onCompletionSelect: (item: CompletionItem) => void; + onCompletionFill?: (item: CompletionItem) => void; + }) => ( +
+
+ {inputText} +
+
{inputText}
+ + + +
+ ), +})); + +import { App } from './App.js'; + +function createDomRect(): DOMRect { + return { + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + toJSON: () => ({}), + } as DOMRect; +} + +function clickButton(container: HTMLDivElement, label: string) { + const button = Array.from(container.querySelectorAll('button')).find( + (candidate) => candidate.textContent === label, + ); + if (!button) { + throw new Error(`Button not found: ${label}`); + } + act(() => { + button.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + }), + ); + }); +} + +function setInputSelection(container: HTMLDivElement, text: string) { + const input = container.querySelector( + '[data-testid="input-field"]', + ) as HTMLDivElement | null; + if (!input) { + throw new Error('Input field not found'); + } + + act(() => { + input.textContent = text; + if (!input.firstChild) { + input.appendChild(document.createTextNode(text)); + } else { + input.firstChild.textContent = text; + } + + const textNode = input.firstChild; + if (!textNode) { + throw new Error('Missing text node'); + } + + const selection = window.getSelection(); + const range = document.createRange(); + range.setStart(textNode, text.length); + range.collapse(true); + selection?.removeAllRanges(); + selection?.addRange(range); + }); +} + +function getRenderedInputText(container: HTMLDivElement): string { + return ( + container.querySelector('[data-testid="input-text"]')?.textContent ?? '' + ); +} + +function renderApp() { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + return { container, root }; +} + +describe('App /skills secondary picker', () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => createDomRect(), + }); + Object.defineProperty(Range.prototype, 'getBoundingClientRect', { + configurable: true, + value: () => createDomRect(), + }); + Object.defineProperty(globalThis, 'ResizeObserver', { + configurable: true, + value: class { + observe() {} + disconnect() {} + }, + }); + Object.defineProperty(globalThis, 'requestAnimationFrame', { + configurable: true, + value: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + }); + Object.defineProperty(globalThis, 'cancelAnimationFrame', { + configurable: true, + value: vi.fn(), + }); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + root = null; + } + if (container) { + container.remove(); + container = null; + } + }); + + it('opens the secondary picker after selecting /skills', async () => { + const rendered = renderApp(); + root = rendered.root; + container = rendered.container; + + await act(async () => {}); + setInputSelection(rendered.container, '/'); + + clickButton(rendered.container, 'select-skills-command'); + + expect(mockPostMessage).not.toHaveBeenCalled(); + expect(mockOpenCompletion).toHaveBeenCalledWith( + '/', + 'skills ', + expect.any(Object), + ); + }); + + it('sends /skills when pressing Enter on a skill item', async () => { + const rendered = renderApp(); + root = rendered.root; + container = rendered.container; + + await act(async () => {}); + setInputSelection(rendered.container, '/skills '); + + clickButton(rendered.container, 'select-skill-enter'); + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: 'sendMessage', + data: { text: '/skills code-review' }, + }); + expect(mockCloseCompletion).toHaveBeenCalled(); + }); + + it('fills /skills without sending when pressing Tab on a skill item', async () => { + const rendered = renderApp(); + root = rendered.root; + container = rendered.container; + + await act(async () => {}); + setInputSelection(rendered.container, '/skills '); + + clickButton(rendered.container, 'select-skill-tab'); + + expect(mockPostMessage).not.toHaveBeenCalled(); + expect(getRenderedInputText(rendered.container)).toBe( + '/skills code-review ', + ); + }); +}); From 594ca21db46f352a8b893e274e14684f5f9fe47f Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 20 Apr 2026 00:04:03 +0800 Subject: [PATCH 10/13] test(vscode-ide-companion): guard App mock initialization --- .../src/webview/App.test.tsx | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/vscode-ide-companion/src/webview/App.test.tsx b/packages/vscode-ide-companion/src/webview/App.test.tsx index ca150148271..53e70510d60 100644 --- a/packages/vscode-ide-companion/src/webview/App.test.tsx +++ b/packages/vscode-ide-companion/src/webview/App.test.tsx @@ -6,20 +6,19 @@ /** @vitest-environment jsdom */ -import React, { act } from 'react'; +import type React from 'react'; +import { act } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRoot, type Root } from 'react-dom/client'; import type { CompletionItem } from '../types/completionItemTypes.js'; -const { - mockPostMessage, - mockOpenCompletion, - mockCloseCompletion, -} = vi.hoisted(() => ({ - mockPostMessage: vi.fn(), - mockOpenCompletion: vi.fn().mockResolvedValue(undefined), - mockCloseCompletion: vi.fn(), -})); +const { mockPostMessage, mockOpenCompletion, mockCloseCompletion } = vi.hoisted( + () => ({ + mockPostMessage: vi.fn(), + mockOpenCompletion: vi.fn().mockResolvedValue(undefined), + mockCloseCompletion: vi.fn(), + }), +); const slashSkillsItem: CompletionItem = { id: 'skills', @@ -106,13 +105,19 @@ vi.mock('./hooks/useWebViewMessages.js', async () => { ) => void; setAvailableSkills: (value: string[]) => void; }) => { + const initializedRef = React.useRef(false); + React.useEffect(() => { + if (initializedRef.current) { + return; + } + initializedRef.current = true; setIsAuthenticated(true); setAvailableCommands([ { name: 'skills', description: 'List available skills' }, ]); setAvailableSkills(['code-review']); - }, []); + }, [setAvailableCommands, setAvailableSkills, setIsAuthenticated]); }, }; }); From e2b2a95081d788e6e3a9f430b2ab15eecc51afac Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 20 Apr 2026 10:36:42 +0800 Subject: [PATCH 11/13] fix(vscode-ide-companion): remove duplicate AvailableCommand import The auto-merge introduced a duplicate AvailableCommand in the @agentclientprotocol/sdk import block, causing TS2300. --- .../src/webview/providers/WebViewProvider.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 2b3b2e75655..26b0369a3dd 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -11,7 +11,6 @@ import type { RequestPermissionRequest, AvailableCommand, ModelInfo, - AvailableCommand, } from '@agentclientprotocol/sdk'; import type { AskUserQuestionRequest } from '../../types/acpTypes.js'; import type { From 600c40a3570adf321652231bad23c3231200d73f Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 20 Apr 2026 18:51:25 +0800 Subject: [PATCH 12/13] fix(vscode-ide-companion): remove duplicate availableCommands replay in handleWebviewReady The handleWebviewReady method was sending cachedAvailableCommands twice on every webview-ready handshake, causing an unnecessary extra state update in the webview. --- .../src/webview/providers/WebViewProvider.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 26b0369a3dd..30c0e81caae 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -1633,13 +1633,6 @@ export class WebViewProvider { }); } - if (this.cachedAvailableCommands !== null) { - this.sendMessageToWebView({ - type: 'availableCommands', - data: { commands: this.cachedAvailableCommands }, - }); - } - // Send cached available models to webview if (this.cachedAvailableModels && this.cachedAvailableModels.length > 0) { console.log( From 816bb270b14ccc87eecb4234a13ba14f83b465f6 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 21 Apr 2026 21:50:43 +0800 Subject: [PATCH 13/13] fix(cli): add ACP supportedModes to /skills command for webview visibility After the capability-based filtering refactor (a82d76672), /skills was excluded from ACP mode because its commandType 'local-jsx' defaults to interactive-only. Add an explicit supportedModes declaration so the command passes the ACP filter and appears in the webview slash command picker. --- packages/cli/src/ui/commands/skillsCommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 801da28c937..23687d8282f 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -24,7 +24,7 @@ export const skillsCommand: SlashCommand = { return t('List available skills.'); }, kind: CommandKind.BUILT_IN, - supportedModes: ['interactive'] as const, + supportedModes: ['interactive', 'acp'] as const, action: async (context: CommandContext, args?: string) => { const rawArgs = args?.trim() ?? ''; const [skillName = ''] = rawArgs.split(/\s+/);