diff --git a/.gitignore b/.gitignore index b7f9b613a..26348eb3e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ ## files generated by popular Visual Studio add-ons. ._local/ +# Local Query Insights prompt override: packaged when present, never committed. +resources/prompts/local/ # User-specific files *.suo diff --git a/build/verification/browser/fixtures.ts b/build/verification/browser/fixtures.ts index b54d3d8ff..3ab96f192 100644 --- a/build/verification/browser/fixtures.ts +++ b/build/verification/browser/fixtures.ts @@ -15,6 +15,7 @@ import { type ClusterDashboardWebviewConfigurationType } from '../../../src/webv import { type CollectionViewWebviewConfigurationType } from '../../../src/webviews/documentdb/collectionView/collectionViewController'; import { type DocumentsViewWebviewConfigurationType } from '../../../src/webviews/documentdb/documentView/documentsViewController'; import { type LocalQuickStartConfigurationType } from '../../../src/webviews/documentdb/localQuickStart/localQuickStartController'; +import { type QueryInsightsPrefill } from '../../../src/webviews/documentdb/queryInsightsStandalone/types'; import { type FixtureInputs, type FixtureOutputs, type RpcFixture, type RpcFixtures } from './core/fixtures'; type Inputs = FixtureInputs; @@ -82,6 +83,10 @@ const quickStartConfig: LocalQuickStartConfigurationType = { initialInstanceState: InstanceState.NotInstalled, }; const atlasConfig: AtlasCredentialsWebviewConfig = { mode: 'add', authMethod: 'apikey' }; +/** A fresh open opens on Welcome; scenarios add {@link queryInsightsSampleExplain} to start on Configure. */ +const queryInsightsConfig: QueryInsightsPrefill = { databaseName: 'demo', collectionName: 'items' }; +export const queryInsightsSampleExplain = + '{"queryPlanner":{"winningPlan":{"stage":"COLLSCAN"}},"executionStats":{"nReturned":2,"executionTimeMillis":12,"totalDocsExamined":100,"totalKeysExamined":0}}'; interface FixtureTableRow { readonly id: string; readonly 'x-objectid': string; @@ -245,6 +250,131 @@ export const fixtures = { 'localQuickStart.onInstanceChanged': { type: 'subscription', results: [instance], keepOpen: true }, }, }, + queryInsightsStandalone: { + config: queryInsightsConfig, + monaco: false, + content: [ + 'Query Insights Explorer', + "What you'll need", + 'Get the explain output', + 'What is sent to GitHub Copilot', + ], + styles: [{ selector: 'body', property: 'padding-top', expected: '0px' }], + rpc: { + ...common, + 'ai.getAvailableModels': reply('query', { + models: [ + { + id: 'copilot-utility', + name: 'Copilot utility', + family: 'copilot-utility', + vendor: 'copilot', + version: '1', + maxInputTokens: 64000, + isUtility: true, + pricing: 'Free', + priceCategory: 'low', + category: 'lightweight', + inputCost: 0, + outputCost: 0, + cacheCost: 0, + supportsToolCalling: true, + supportsImageToText: false, + }, + ], + savedModelId: null, + effectiveModelId: 'copilot-utility', + effectiveReason: 'first-available', + }), + 'queryInsightsStandalone.validateInput': reply( + 'query', + { + commandType: 'find' as Outputs['queryInsightsStandalone']['validateInput']['commandType'], + query: '{}', + }, + ), + 'queryInsightsStandalone.inspectInput': reply('query', { + explain: { + ok: true, + detectedCommandType: 'find' as Outputs['queryInsightsStandalone']['validateInput']['commandType'], + planStages: ['COLLSCAN'], + }, + commandType: 'find' as Outputs['queryInsightsStandalone']['validateInput']['commandType'], + query: '{}', + queryIsExtracted: true, + ready: true, + }), + 'queryInsightsStandalone.getStage1': reply('query', { + executionTime: 0, + stages: [{ stage: 'COLLSCAN', name: 'Collection scan', nReturned: 0 }], + efficiencyAnalysis: { executionStrategy: 'Collection scan', indexUsed: null, hasInMemorySort: false }, + }), + 'queryInsightsStandalone.getStage2': reply('query', { + executionTimeMs: 12, + documentsReturned: 2, + totalDocsExamined: 100, + totalKeysExamined: 0, + examinedToReturnedRatio: 50, + keysToDocsRatio: 0, + executionStrategy: 'Collection scan', + indexUsed: false, + usedIndexNames: [], + hadInMemorySort: false, + hadCollectionScan: true, + isCoveringQuery: false, + concerns: [], + rawExecutionStats: {}, + stages: [{ stage: 'COLLSCAN', name: 'Collection scan', nReturned: 2 }], + efficiencyAnalysis: { + selectivity: null, + indexUsed: null, + fetchOverhead: 'Collection scan', + fetchOverheadKind: 'collectionScan', + hasInMemorySort: false, + performanceRating: { score: 'poor', diagnostics: [] }, + }, + }), + 'queryInsightsStandalone.streamStage3': { + type: 'subscription', + results: [ + { type: 'summary', markdown: 'Consider indexing the filter.', complete: true }, + { type: 'recommendationStarted', index: 0 }, + { + type: 'recommendation', + index: 0, + recommendation: { + action: 'create', + indexSpec: { status: 1 }, + indexName: 'status_1', + shellCommand: 'db.items.createIndex({status: 1})', + justification: 'Avoid a collection scan.', + priority: 'high', + }, + }, + { + type: 'complete', + modelId: 'copilot-utility', + modelFamily: 'copilot-utility', + modelDisplayName: 'Copilot utility', + durationMs: 10, + usage: { + promptTokens: 1200, + responseTokens: 350, + credits: 0, + reportedPromptTokens: 1200, + reportedCompletionTokens: 350, + usageSource: 'provider', + }, + }, + ] as const, + }, + 'common.copyToClipboard': reply('mutation', true), + 'queryInsightsStandalone.openRawExplain': reply( + 'mutation', + true, + ), + }, + }, atlasCredentials: { config: atlasConfig, monaco: false, diff --git a/build/verification/browser/harness.test.ts b/build/verification/browser/harness.test.ts index f80f9a187..34cd339e1 100644 --- a/build/verification/browser/harness.test.ts +++ b/build/verification/browser/harness.test.ts @@ -165,13 +165,14 @@ describe('Stage 0 L2 production browser harness', (): void => { } }); - it('covers all five registry keys with populated fixture contracts', (): void => { + it('covers all six registry keys with populated fixture contracts', (): void => { expect(Object.keys(fixtures).sort()).toEqual([ 'atlasCredentials', 'clusterDashboard', 'collectionView', 'documentView', 'localQuickStart', + 'queryInsightsStandalone', ]); for (const fixture of Object.values(fixtures)) { expect(fixture.content.length).toBeGreaterThan(0); diff --git a/build/verification/browser/runtime.ts b/build/verification/browser/runtime.ts index ffd973bd1..ef34351b9 100644 --- a/build/verification/browser/runtime.ts +++ b/build/verification/browser/runtime.ts @@ -66,6 +66,7 @@ function isHarnessFixture(value: unknown): value is HarnessFixture { documentView: true, localQuickStart: true, atlasCredentials: true, + queryInsightsStandalone: true, } satisfies Record; return ( isObject(value) && diff --git a/build/verification/browser/scenarios.test.ts b/build/verification/browser/scenarios.test.ts index 2fb8766b1..a076eb865 100644 --- a/build/verification/browser/scenarios.test.ts +++ b/build/verification/browser/scenarios.test.ts @@ -12,7 +12,7 @@ import { parseScenarioRoute, runnerRoutes, scenarioHtml, scenarioIndex, scenario import { findScenario, scenarioRoutes, scenarios, type Scenario } from './scenarios'; describe('L2-dev manifest and routes', (): void => { - it('imports, rather than duplicates, all five settled L2 fixtures', (): void => { + it('imports, rather than duplicates, all six settled L2 fixtures', (): void => { for (const route of scenarioRoutes().filter((route): boolean => route.scenario === 'default')) { const scenario = findScenario(route); expect(scenario.config).toBe(fixtures[route.view].config); @@ -23,8 +23,8 @@ describe('L2-dev manifest and routes', (): void => { it('round-trips every scenario and theme, without duplicate routes', (): void => { const routes = scenarioRoutes(); - expect(routes).toHaveLength(42); - expect(new Set(routes.map((route): string => route.path)).size).toBe(42); + expect(routes).toHaveLength(72); + expect(new Set(routes.map((route): string => route.path)).size).toBe(72); for (const route of routes) expect(parseScenarioRoute(route.path)).toEqual(route); }); @@ -50,7 +50,8 @@ describe('L2-dev manifest and routes', (): void => { }); it('provides path-only all/view/theme helpers and rejects unknown subsets', (): void => { - expect(runnerRoutes('/scenarios/run-all.js')).toHaveLength(42); + expect(runnerRoutes('/scenarios/run-all.js')).toHaveLength(72); + expect(runnerRoutes('/scenarios/run-all/queryInsightsStandalone.js')).toHaveLength(30); expect(runnerRoutes('/scenarios/run-all/localQuickStart.js')).toHaveLength(30); expect(runnerRoutes('/scenarios/run-all/localQuickStart/dark.js')).toHaveLength(10); expect(runnerRoutes('/scenarios/run-all/unknown.js')).toBeUndefined(); diff --git a/build/verification/browser/scenarios.ts b/build/verification/browser/scenarios.ts index 7266ed519..6a826a4a2 100644 --- a/build/verification/browser/scenarios.ts +++ b/build/verification/browser/scenarios.ts @@ -3,7 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { type AIIndexRecommendation } from '../../../src/services/ai/types'; import { type WebviewName } from '../../../src/webviews/_integration/WebviewRegistry'; +import { previewAvailableModels } from '../../../src/webviews/documentdb/queryInsightsShared/designPreview/mockModels'; import { type FixtureAtPath, type FixtureInputs, @@ -11,7 +13,7 @@ import { type FixturePaths, type TypedRpcFixtures, } from './core/fixtures'; -import { fixtures, type StyleExpectation } from './fixtures'; +import { fixtures, queryInsightsSampleExplain, type StyleExpectation } from './fixtures'; export interface ScenarioReadiness { readonly content: readonly string[]; @@ -75,6 +77,258 @@ function defaultScenario(view: V, steps: readonly Scenari }; } +const explorerBase = fixtures.queryInsightsStandalone; +// A prefilled explain output opens the explorer on Configure. +const explorerPrefilled = { ...explorerBase.config, explain: queryInsightsSampleExplain }; +const explorerSteps = [ + { action: 'click', role: 'button', name: 'Analyze query' }, +] as const satisfies readonly ScenarioStep[]; +const explorerStage2 = explorerBase.rpc['queryInsightsStandalone.getStage2'].results[0]; +const explorerInspection = explorerBase.rpc['queryInsightsStandalone.inspectInput'].results[0]; +type ExplorerInspection = FixtureOutputs['queryInsightsStandalone']['inspectInput']; +const explorerAnalysisInput = { + explain: queryInsightsSampleExplain, + query: '{}', + commandType: 'find', + collectionStats: '', + indexes: '', + databaseName: 'demo', + collectionName: 'items', + clusterType: 'azure-documentdb', + modelId: 'copilot-utility', +} as const; + +function explorerConfigure( + inspection: ExplorerInspection, + content: readonly string[], + extra: { config?: Partial; steps?: readonly ScenarioStep[] } = {}, +): Scenario<'queryInsightsStandalone'> { + return { + config: { ...explorerPrefilled, ...extra.config }, + rpc: { ...explorerBase.rpc, 'queryInsightsStandalone.inspectInput': { type: 'query', results: [inspection] } }, + steps: [...(extra.steps ?? [])], + readiness: { content, selectors: ['.monaco-editor .view-lines'], noProgressbar: true }, + }; +} + +function explorerProfile(profile: 'aggregate' | 'count'): Scenario<'queryInsightsStandalone'> { + const isCount = profile === 'count'; + const commandType = profile as FixtureOutputs['queryInsightsStandalone']['validateInput']['commandType']; + const stage2: FixtureOutputs['queryInsightsStandalone']['getStage2'] = { + ...explorerStage2, + analysisProfile: profile, + documentsReturned: isCount ? 80 : 35, + totalDocsExamined: isCount ? 0 : 100, + totalKeysExamined: isCount ? 80 : 0, + notApplicable: isCount + ? ['selectivity', 'fetchOverhead', 'inMemorySort', 'examinedToReturnedRatio'] + : ['inMemorySort'], + analysisNotes: isCount + ? [] + : [ + 'Only the initial cursor stage ($match/$sort) is analyzed; later pipeline stages ($group, $sort) are not rated.', + ], + efficiencyAnalysis: { + ...explorerStage2.efficiencyAnalysis, + selectivity: isCount ? null : 17.5, + ...(isCount + ? { indexUsed: 'status_1', performanceRating: { score: 'excellent' as const, diagnostics: [] } } + : {}), + }, + ...(isCount + ? { + executionStrategy: 'Index-only count', + indexUsed: true, + usedIndexNames: ['status_1'], + hadCollectionScan: false, + isCoveringQuery: true, + keysToDocsRatio: null, + stages: [ + { stage: 'COUNT', name: 'Count', nReturned: 0 }, + { stage: 'COUNT_SCAN', name: 'Index count scan', nReturned: 80 }, + ], + } + : {}), + }; + const initial = explorerBase.rpc['queryInsightsStandalone.getStage1'].results[0]; + const stage1: FixtureOutputs['queryInsightsStandalone']['getStage1'] = isCount + ? { + ...initial, + stages: stage2.stages, + efficiencyAnalysis: { + executionStrategy: 'Index-only count', + indexUsed: 'status_1', + hasInMemorySort: false, + }, + } + : initial; + const cursor = { + queryPlanner: { winningPlan: { stage: 'COLLSCAN' }, parsedQuery: { status: 'active' } }, + executionStats: { + executionTimeMillis: 12, + nReturned: 35, + totalDocsExamined: 100, + totalKeysExamined: 0, + executionStages: { stage: 'COLLSCAN', nReturned: 35, docsExamined: 100 }, + }, + }; + const explain = isCount + ? { + queryPlanner: { + winningPlan: { stage: 'COUNT', inputStage: { stage: 'COUNT_SCAN', indexName: 'status_1' } }, + }, + executionStats: { + executionTimeMillis: 12, + nReturned: 0, + totalDocsExamined: 0, + totalKeysExamined: 80, + executionStages: { + stage: 'COUNT', + nCounted: 80, + inputStage: { stage: 'COUNT_SCAN', indexName: 'status_1' }, + }, + }, + command: { count: 'items', query: { status: 'active' } }, + } + : { + stages: [ + { $cursor: cursor }, + { $group: { _id: '$status' }, nReturned: 1 }, + { $sort: { _id: 1 }, nReturned: 1 }, + ], + }; + return { + config: { ...explorerPrefilled, explain: JSON.stringify(explain), commandType: profile }, + rpc: { + ...explorerBase.rpc, + 'queryInsightsStandalone.inspectInput': { + type: 'query', + results: [ + { + ...explorerInspection, + explain: { + ok: true, + detectedCommandType: commandType, + databaseName: 'shop', + collectionName: 'items', + planStages: isCount ? ['COUNT_SCAN', 'COUNT'] : ['COLLSCAN', '$group', '$sort'], + }, + commandType, + query: isCount ? '{}' : '[]', + }, + ], + }, + 'queryInsightsStandalone.getStage2': { type: 'query', results: [stage2] }, + 'queryInsightsStandalone.getStage1': { type: 'query', results: [stage1] }, + }, + steps: explorerSteps, + readiness: { + content: [ + 'Feature preview', + 'Preview · tuned for find queries', + 'Query Plan Summary', + 'Copy command', + isCount ? 'Documents counted' : 'Documents passed to pipeline', + isCount ? 'Not applicable' : 'Not rated for pipeline $sort', + ], + selectors: ['.leftColumn', '.rightColumn'], + noProgressbar: true, + }, + }; +} + +/** Advice recommendations (`action: "none"`): copy-only cards without an index block. */ +function explorerInsights(): Scenario<'queryInsightsStandalone'> { + const verifyCommand = 'db.getCollection("items").find({}).hint({ $natural: 1 }).explain("executionStats")'; + const advice = ( + index: number, + recommendation: Omit & + Partial, + ) => + [ + { type: 'recommendationStarted', index }, + { + type: 'recommendation', + index, + recommendation: { indexSpec: {}, indexName: '', shellCommand: '', ...recommendation }, + }, + ] as const; + return { + ...defaultScenario('queryInsightsStandalone'), + config: explorerPrefilled, + steps: explorerSteps, + rpc: { + ...explorerBase.rpc, + 'queryInsightsStandalone.streamStage3': { + type: 'subscription', + results: [ + { + type: 'summary', + markdown: + '### Key Issues\n1. 🔻 Critical: collection scan over 64,694 documents.\n2. 🔼 Good: the filter is simple.', + complete: true, + }, + ...advice(0, { + action: 'none', + kind: 'verify', + justification: 'Compare the current plan with a collection-order scan before changing indexes.', + shellCommand: verifyCommand, + priority: 'medium', + }), + ...advice(1, { + action: 'none', + justification: + 'Query change: The predicate returns 91.9% of the collection. Add a more selective condition or a limit.', + risks: 'Changing filters or limits changes the results returned to the application.', + priority: 'low', + }), + ...advice(2, { + action: 'none', + kind: 'support', + justification: 'Planner statistics could help choose the selective index.', + priority: 'low', + }), + { + type: 'complete', + modelId: 'copilot-utility', + modelFamily: 'copilot-utility', + modelDisplayName: 'Copilot utility', + durationMs: 10, + modelInputs: { + userQuery: '{}', + contextData: '## Collection Information\n- **Collection_Stats**: N/A', + promptSource: 'resource-file', + }, + }, + ], + }, + }, + readiness: { + content: [ + 'Verify', + 'Change the Query', + 'Contact Support', + 'Suggested command', + verifyCommand, + 'Changing filters or limits changes the results returned to the application.', + 'Share', + ], + selectors: ['.leftColumn', '.rightColumn'], + noProgressbar: true, + }, + assertions: [ + { + kind: 'calls', + name: 'Advice cards copy their command', + path: 'common.copyToClipboard', + input: { text: verifyCommand }, + count: 1, + steps: [{ action: 'click', role: 'button', name: 'Copy command' }], + }, + ], + }; +} + const base = fixtures.localQuickStart; const readyDocker = base.rpc['localQuickStart.getDockerStatus'].results[0]; const completedStages = [ @@ -144,6 +398,231 @@ function dockerMissing( } export const scenarios = { + queryInsightsStandalone: { + default: { + ...defaultScenario('queryInsightsStandalone'), + assertions: [ + ...(defaultScenario('queryInsightsStandalone').assertions ?? []), + { + kind: 'calls', + name: 'Welcome snippets copy through the shared clipboard RPC', + path: 'common.copyToClipboard', + count: 1, + input: { text: 'db.orders.explain("executionStats").count({ status: "pending" })' }, + steps: [{ action: 'click', role: 'button', name: 'Copy the count command' }], + }, + ], + }, + configure: explorerConfigure(explorerInspection, [ + 'Provide your query data', + 'Explain output parsed.', + 'Extracted query', + 'Optional context', + 'Not provided', + 'demo.items', + 'Copilot utility', + 'No additional cost for most GitHub Copilot subscribers.', + 'Analyze query sends your inputs to Copilot utility.', + 'AI responses may be inaccurate', + ]), + 'configure-models': { + // Opens the details popover, so its facts are part of the readiness text. + ...explorerConfigure( + explorerInspection, + [ + 'GPT Example', + 'The selected model may use premium requests.', + 'Default: first Powerful model', + 'Very high cost', + 'Credits per 1M tokens', + 'Long context', + '921,793 tokens', + 'gpt-example', + ], + { steps: [{ action: 'click', role: 'button', name: 'Model details' }] }, + ), + rpc: { + ...explorerConfigure(explorerInspection, []).rpc, + 'ai.getAvailableModels': { + type: 'query', + results: [ + { + models: [ + { + id: 'copilot-utility', + name: 'Copilot utility', + family: 'copilot-utility', + vendor: 'copilot', + version: '1', + maxInputTokens: 64000, + isUtility: true, + pricing: 'Free', + priceCategory: 'low', + category: 'lightweight', + inputCost: 0, + outputCost: 0, + cacheCost: 0, + }, + { + id: 'gpt-example', + name: 'GPT Example', + family: 'gpt-example', + vendor: 'copilot', + version: 'gpt-example', + maxInputTokens: 921793, + isUtility: false, + pricing: '1x', + priceCategory: 'very_high', + category: 'powerful', + inputCost: 800, + outputCost: 4000, + cacheCost: 80, + cacheWriteCost: 1000, + longContextInputCost: 1600, + longContextOutputCost: 6000, + longContextCacheCost: 160, + longContextCacheWriteCost: 2000, + supportsToolCalling: true, + supportsImageToText: true, + }, + { + id: 'unpriced', + name: 'Unpriced model', + family: 'unpriced', + vendor: 'copilot', + version: '1', + maxInputTokens: 200000, + isUtility: false, + category: 'versatile', + }, + // Enough models that the listbox must scroll within the webview. + ...Array.from({ length: 14 }, (_, index) => ({ + id: `example-${index + 1}`, + name: `Example model ${index + 1}`, + family: `example-${index + 1}`, + vendor: 'copilot', + version: '1', + maxInputTokens: 128000, + isUtility: false, + category: ['powerful', 'versatile', 'lightweight'][index % 3], + priceCategory: ['low', 'medium', 'high'][index % 3], + })), + ], + savedModelId: null, + effectiveModelId: 'gpt-example', + effectiveReason: 'first-powerful', + }, + ], + }, + }, + }, + // Dev-only design comparison for the Collection View tab's AI card (not a wizard state). + 'tab-model-options': { + config: { ...explorerBase.config, designPreview: 'tab-model-selection' }, + rpc: { + ...explorerBase.rpc, + 'ai.getAvailableModels': { type: 'query', results: [previewAvailableModels] }, + 'ai.setSelectedModel': { + type: 'mutation', + results: [ + { + ...previewAvailableModels, + savedModelId: 'copilot-utility', + effectiveModelId: 'copilot-utility', + effectiveReason: 'saved', + }, + ], + }, + }, + readiness: { + content: [ + 'Query Insights — selected card design', + 'Preview fixture models only.', + 'Get AI Performance Insights', + 'Learn more about AI Performance Insights', + ], + noProgressbar: true, + }, + }, + 'configure-invalid': explorerConfigure( + { explain: { ok: false, message: 'Unexpected token', line: 3, column: 7 }, ready: false }, + ['Line 3, column 7: Unexpected token', 'Fix the explain output to continue.'], + { config: { explain: '{\n "executionStats": {\n !!bad\n }\n}' } }, + ), + 'configure-context': explorerConfigure( + { + ...explorerInspection, + collectionStats: { ok: true, documentCount: 48210 }, + indexes: { ok: true, names: ['_id_', 'status_1'] }, + }, + ['Provided · 48,210 documents', 'Provided · 2 indexes', '_id_, status_1', 'getIndexes()'], + { + config: { collectionStats: '{"count":48210}', indexes: '[{"name":"_id_"},{"name":"status_1"}]' }, + steps: [{ action: 'click', role: 'button', name: 'Edit indexes' }], + }, + ), + analyze: { + ...defaultScenario('queryInsightsStandalone'), + config: explorerPrefilled, + steps: explorerSteps, + readiness: { + content: [ + 'Feature preview', + 'Query Efficiency Analysis', + 'Query Performance Analysis', + 'Consider indexing the filter.', + 'Copy command', + 'Reconfigure', + 'demo.items', + '0 credits', + '1,200 in / 350 out tokens', + ], + selectors: ['.leftColumn', '.rightColumn'], + noProgressbar: true, + }, + assertions: [ + { + kind: 'calls', + name: 'Explorer recommendations are copy-only', + path: 'common.copyToClipboard', + input: { text: 'db.items.createIndex({status: 1})' }, + count: 1, + steps: [{ action: 'click', role: 'button', name: 'Copy command' }], + }, + { + kind: 'calls', + name: 'Raw explain uses the connectionless router', + path: 'queryInsightsStandalone.openRawExplain', + count: 1, + input: explorerAnalysisInput, + steps: [{ action: 'click', role: 'button', name: 'View Raw Execution Stats' }], + }, + { + kind: 'calls', + name: 'Reconfigure preserves inputs for another analysis', + path: 'queryInsightsStandalone.getStage2', + count: 2, + input: explorerAnalysisInput, + steps: [ + { action: 'click', role: 'button', name: 'Reconfigure' }, + { action: 'click', role: 'button', name: 'Analyze query' }, + ], + }, + { + // Wait for the new recommendation so readiness checks finished content. + kind: 'calls', + name: 'Reconfigured analysis streams recommendations again', + path: 'common.copyToClipboard', + input: { text: 'db.items.createIndex({status: 1})' }, + count: 2, + steps: [{ action: 'click', role: 'button', name: 'Copy command' }], + }, + ], + }, + 'analyze-aggregate': explorerProfile('aggregate'), + 'analyze-count': explorerProfile('count'), + 'analyze-insights': explorerInsights(), + }, collectionView: { default: defaultScenario('collectionView') }, documentView: { default: defaultScenario('documentView') }, clusterDashboard: { default: defaultScenario('clusterDashboard') }, diff --git a/docs/ai-and-plans/features/query-insights/README.md b/docs/ai-and-plans/features/query-insights/README.md index c9b4e7950..7a899f92d 100644 --- a/docs/ai-and-plans/features/query-insights/README.md +++ b/docs/ai-and-plans/features/query-insights/README.md @@ -51,12 +51,13 @@ the user waits. ## Timeline -| Date | PR | What changed | Docs | -| ---------- | ---- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| 2026-04-27 | — | Performance rating: plan and implementation notes | [iterations/01-performance-rating.md](./iterations/01-performance-rating.md) | -| 2026-04-27 | #616 | AI analysis aligned with the static analysis | [iterations/02-ai-static-analysis-alignment.md](./iterations/02-ai-static-analysis-alignment.md) | -| 2026-05-28 | #690 | AI model transparency in the panel | [iterations/03-ai-model-transparency.md](./iterations/03-ai-model-transparency.md) | -| 2026-06-01 | #711 | Progressive streaming for Stage 3 recommendations | [iterations/04-streaming-stage-3/](./iterations/04-streaming-stage-3/) | +| Date | PR | What changed | Docs | +| ---------- | ---- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| 2026-04-27 | — | Performance rating: plan and implementation notes | [iterations/01-performance-rating.md](./iterations/01-performance-rating.md) | +| 2026-04-27 | #616 | AI analysis aligned with the static analysis | [iterations/02-ai-static-analysis-alignment.md](./iterations/02-ai-static-analysis-alignment.md) | +| 2026-05-28 | #690 | AI model transparency in the panel | [iterations/03-ai-model-transparency.md](./iterations/03-ai-model-transparency.md) | +| 2026-06-01 | #711 | Progressive streaming for Stage 3 recommendations | [iterations/04-streaming-stage-3/](./iterations/04-streaming-stage-3/) | +| 2026-10-20 | — | Model picker, local prompts and connectionless explorer — **POC, not shipped** | [iterations/05-query-insights-next/plan.md](./iterations/05-query-insights-next/plan.md) | ## Decisions diff --git a/docs/ai-and-plans/features/query-insights/iterations/05-query-insights-next/plan.md b/docs/ai-and-plans/features/query-insights/iterations/05-query-insights-next/plan.md new file mode 100644 index 000000000..cbbad1924 --- /dev/null +++ b/docs/ai-and-plans/features/query-insights/iterations/05-query-insights-next/plan.md @@ -0,0 +1,924 @@ +--- +feature: query-insights +kind: plan +status: active +created: 2026-10-20 +branch: dev/tnaum/query-insights-next +--- + +# Query Insights Next (POC): model picker, local prompt, explore without a connection + +> **Proof of concept.** Dirty code is acceptable. The aim is to experiment with an internal +> prompt and with different models, with and without a live connection. Anything that graduates +> gets cleaned up and re-planned. This file is the source of truth while the work runs. +> Progress, time spent and deviations are recorded inline. + +## Goals + +1. **Local, uncommitted prompt.** Run Query Insights Stage 3 with an internal prompt that lives + only on the developer's machine. +2. **Model picker.** Choose the Copilot model for Query Insights in the existing tab and in the + new view. `copilot-utility` stays selectable. +3. **Explore without a connection.** A wizard webview: Welcome → Configure (paste explain plan, + collStats, indexes; pick a model) → Analyze (the existing Query Insights layout, straight + into Stage 2 + Stage 3). + +## Operator decisions (2026-10-20) + +| # | Topic | Decision | Changed from the proposal? | +| --- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| D1 | Where the prompt and code live | The prompt is a **local file only** (`._local/prompts/`, gitignored), loaded through the existing `documentDB.aiAssistant.indexAdvisor*PromptPath` settings. Code goes on a public branch. No private repo. | No (option 1A) | +| D2 | Output format | The operator adapts the internal prompt to emit our JSON contract (`{educationalContent, analysis, improvements[]}`). **No format suffix message.** | Yes. The proposal was "contract suffix + markdown fallback". | +| D3 | Default model | Saved choice → **first model in the list**. The org default would be preferred, but it isn't reachable (see F1). | Yes. The proposal was `copilot-utility` by default. | +| D4 | Model preference scope | **One shared** `globalState` key for the tab and the standalone view. | No | +| D5 | Webview reuse | Reuse **as much as possible, including the existing layout** (left column: AI cards; right column: efficiency analysis + query plan). | Strengthened: the proposal reused only the cards. | +| D6 | Entry points | A command + an **"Explore with your own input…" link in the Query Insights tab** (pre-filled from the session) + an **item in Help and Feedback** (labelled as a POC). No new "Tools" view. | Yes. The proposal was a Connections view title-bar action. | + +## Findings + +- **F1: the organization default model isn't available to extensions.** In `microsoft/vscode` + (`extHostLanguageModels.ts`), the default (`metadata.isDefaultForLocation[Chat]`) is only used by + `getDefaultLanguageModel` / `getLanguageModelForRequest`, which hand the model to **chat + participants** as `request.model`. The `LanguageModelChat` returned by + `vscode.lm.selectChatModels` has no `isDefault`, and the list comes back in provider + registration order. Only workarounds: a chat participant, which can't feed a webview, or a + proposed API, which can't ship on the Marketplace. +- **F2: custom prompt files already work** (`PromptTemplateService`). The templates are cached + by default (`documentDB.aiAssistant.enablePromptCache`, not contributed, default `true`), so + edits only apply after a reload. WI-1 fixes that for custom files. +- **F3: Stage 2 is pure.** `ExplainPlanAnalyzer` + `StagePropertyExtractor` + + `transformStage2Response` already run on a debug JSON file with no connection + (`readQueryInsightsDebugFile` in `queryInsightsRouter.ts`). +- **F4: `QueryInsightsMain` is loosely coupled.** It depends on `CollectionViewContext` + (`queryInsights` pipeline state and `isLoading`), `useConfiguration` (`clusterId`, + `databaseName`, `collectionName`), and about six tRPC calls + (`mongoClusters.collectionView.queryInsights.{getQueryInsightsStage1, getQueryInsightsStage2, streamStage3, executeQueryInsightsAction}`, + `common.openUrl`, `common.explainOperationFailure` / `displayErrorMessage`). A small adapter + can make it mountable outside the Collection View. +- **F5: the cosmosdb model picker** (`vscode-cosmosdb/src/utils/aiUtils.ts`, `MigrationAssistant.tsx`): + `selectChatModels({vendor:'copilot'})` minus `auto`, `{id,name,family,vendor,maxInputTokens}`, + saved id in `globalState`, and a Fluent `Dropdown` showing the name + "N tokens" (a recommended + group first, then an "Others" `OptionGroup`). +- **F6: non-find applicability (lead-agent evaluation, checked against installed code).** + Stage 2 and plan rendering were designed for find; accepting another command in the wizard + does not make its static rules applicable. Inspection of the installed + `@mongodb-js/explain-plan-helper/dist/get-execution-stats.js` corrects two lead hypotheses: + the helper **replaces cursor `nReturned` with the last pipeline stage's `nReturned`**, and + **prefers cursor `executionTimeMillis`**, falling back to the last stage's estimate. It also + collects `indexesUsed` from later pipeline stages. Therefore the old efficiency ratio can + mix final pipeline output with initial cursor examination; it is not already a valid + `$match` selectivity ratio. Normalizing to the initial cursor before analysis avoids that. + + | Element | find | aggregate | count | + | ---------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | + | Docs/keys examined, exec time | Valid | Cursor docs/keys; helper prefers cursor time, otherwise last-stage estimate | Valid | + | Docs returned + efficiency ratio | Valid | Helper uses final-stage returns with cursor examination; use cursor returns and relabel "Documents passed to pipeline" | COUNT `nReturned=0` is not a match count; use execution-stage `nCounted`, never infer no matches from zero returned documents | + | Selectivity / coverage advisories | Valid | Initial cursor / pushed-down filter only, explicitly scoped | Not applicable; suppress | + | In-memory sort detection | Valid | Only cursor SORT is detected; a later `$sort` must not be labelled "No" | Not applicable | + | Index usage, bitmap, low-cardinality, multikey | Valid | Cursor portion (helper can additionally report later-stage indexes) | Applicable; COUNT_SCAN is recognized as indexed | + | Plan visualization / extended stage info | Valid | Current transformer/extractor only read top-level fields; normalize `stages[0].$cursor` first | COUNT/COUNT_SCAN trees can be shown; numeric result remains `nCounted` | + + A further coupling was found while checking F6: QueryPlanSummary's raw-explain button calls + a Collection View session RPC directly. The standalone must supply its own raw-plan action. + Optimized single-plan aggregates containing transformations may not expose an initial cursor + boundary; those initial-return/selectivity/rating fields must be unavailable rather than + relabelling final output as cursor throughput. If cursor timing is missing, a last-stage + estimate describes the whole pipeline, not cursor-only time; it cannot justify a fast + initial-stage rating. + +- **F7: model pricing metadata (lead research, verified against `microsoft/vscode`).** + `selectChatModels` returns plain frozen objects with additional runtime properties: + `pricing?: string` (display label), `priceCategory?: string` (`low`, `medium`, `high`, + `very_high`), `category?: string` (`lightweight`, `versatile`, `powerful`, or others), + `capabilities?: { supportsImageToText, supportsToolCalling, ... }`, and numeric + `inputCost`, `outputCost`, `cacheCost`, `cacheWriteCost`, `longContextInputCost`, + `longContextOutputCost`, `longContextCacheCost`, `longContextCacheWriteCost`. + Costs are **AI credits per 1M tokens**. Typings are proposed + (`vscode.proposed.languageModelPricing.d.ts`), but property reads are not proposal-gated. + Copilot populates these from endpoint metadata in + `extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts` + (around line 375). The POC uses an `unknown`-typed, field-by-field defensive reader. +- **F8: per-request credits are observable (lead research, verified against `microsoft/vscode`).** + At response completion Copilot emits a stable `LanguageModelDataPart` with MIME `usage` + containing UTF-8 JSON `APIUsage`: `prompt_tokens`, `completion_tokens`, `total_tokens`, + optional `prompt_tokens_details.{cached_tokens,cache_creation_input_tokens}`, + `completion_tokens_details.{reasoning_tokens,...}`, and + `copilot_usage.total_nano_aiu`. Credits = `total_nano_aiu / 1_000_000_000` + (Copilot's `nanoAiuToCredits`). `response.stream` forwards text/data/thinking/tool-call + parts; `response.text` drops non-text parts. `LanguageModelDataPart` is stable in this + repo's `@types/vscode` 1.109. Real token counts and exact billed credits therefore need + **no proposed API**. + +- **F9: Chat model settings already apply to extension requests (lead research, verified + against `microsoft/vscode`).** `languageModels.ts` `sendChatRequest` merges user model + configuration into **every** LM API request: + `mergedOptions = { ...options, configuration: { ...userConfig, ...options.configuration } }`. + Query Insights therefore already follows the thinking level (`reasoningEffort`) and context + size (`contextSize`) chosen for that model in the VS Code Chat model picker. The supported + values (`configurationSchema`) are **not** exposed on `LanguageModelChat`. An undocumented + `configuration` request option could override the merge but is a hack and is not implemented. + The details panel explains the inherited settings; no new thinking/context controls. + `priceCategory` is a **server-side relative tier**, not calculated from per-token prices: + Copilot reads CAPI model-list field `model_picker_price_category` in + `extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts`. + VS Code `modelPickerPresentation.ts` `getPriceCategoryLabel` maps low/medium/high/very_high + to Low/Medium/High/Very high cost; unknown values become "{Capitalized} cost"; missing means + no badge. It uses only two styles: low/medium neutral (`informative` tint here), + high/very_high warning (VS Code mixes editorWarning-foreground at 14%; `warning` tint here). + No success/danger colour mapping. + +### F10 — What we can ship + +Lead research, verified in the `microsoft/vscode` and `microsoft/vscode-vsce` sources. + +| Data | API status | Gated at runtime? | Shippable on Marketplace without flags? | Fallback | +| ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| Model list, id/name/family/version, maxInputTokens, countTokens, sendRequest, `response.stream`, `LanguageModelDataPart` | Stable (`vscode.d.ts`) | No | Yes | — | +| Per-run usage/credits (`'usage'` data part, `copilot_usage.total_nano_aiu`) | Stable channel, but the payload is a Copilot-provider-internal contract (undocumented) | No | Yes | Missing/malformed → `countTokens` estimates, no credits shown | +| Pricing/priceCategory/category on `LanguageModelChat` | Proposed (`languageModelPricing`); typed only in the proposed d.ts, but set as plain properties on the model object with no `checkProposedApiEnabled` | No (today) | Yes, as long as we do NOT declare `enabledApiProposals`, only read defensively via `unknown` | Absent → hide the Pricing details panel | +| Thinking level / context size override (`configuration` in request options) | Undocumented pass-through | No | Technically yes, but not used | User's VS Code per-model settings apply automatically | + +Rules: + +- Declaring `enabledApiProposals` in package.json would block the release: vsce refuses to publish it to the Marketplace unless it's explicitly allowed. In stable VS Code, proposals only activate for extensions on the product's `extensionEnabledApiProposals` allow-list (GitHub Copilot Chat, for example) or with `--enable-proposed-api`. So we never declare it. +- Gated proposals (e.g. `LanguageModelChatMessageRole.System`, which calls `checkProposedApiEnabled`) throw at runtime, so they're off-limits. +- Risk of the ungated reads: VS Code may rename, remove or gate these fields in any release, or finalize them into the stable API, without notice. Hence feature detection on every read, a hidden UI when they're absent, and a unit test proving the picker renders with none of the optional fields. + +## Rules for the implementing agent + +1. Work **WI by WI**, in order. Don't produce one giant changeset. +2. **Commit per WI** on `dev/tnaum/query-insights-next` (conventional message, e.g. + `feat(query-insights): model picker RPCs (WI-2)`, with the `Co-authored-by: Copilot` trailer). + **Do not push.** Never `git add -f`. Never commit anything under `._local/`. +3. After each WI, update this file: tick the box, add a one-line `_Outcome:_`, and fill the + **Time log** row (wall-clock start/end from `date -u +%H:%M`, and minutes). +4. Record any divergence from this plan in the **Deviation log** with its rationale. If + confidence in a non-obvious decision is below 80 %, stop and report instead of guessing. +5. **Verification is batched** (operator request): don't build or test after every WI. Run + only the listed **checkpoints** (Case 1: `npm run build` + targeted `npx vitest run `). + Fix failures before moving on. No `lint`, `prettier-fix`, `l10n` or `package` (POC, Case 1). +6. Terminology: "DocumentDB" / "DocumentDB API", never "MongoDB" alone. User-facing strings use + `vscode.l10n.t()` (host) / `l10n.t()` (webview). +7. Keep the shipped behaviour of the Collection View intact apart from the additions listed here. +8. Skills to consult as needed: `.github/skills/webview-trpc-messaging`, + `.github/skills/react-webview-architecture`, `.github/skills/fluentui`. + +## Work items + +### Phase A: model picker and local prompt in the existing tab + +- [x] **WI-0: Model selection host plumbing** _(started before the review; finish it)_ + - Done: `src/services/ai/modelSelection.ts` (list the models minus `auto`, ensure + `copilot-utility` is listed, shared key `documentDB.queryInsights.selectedModelId`, resolve + explicit → saved → first). `CopilotService.{sendMessage,streamMessage}` options + `modelSelection: 'utility' | 'user'` + `modelId`, plus a private `selectModel()`. Query + Insights (`optimizeQuery*`) passes `modelSelection: 'user'`. Query generation is unchanged + (utility). `QueryOptimizationContext` gets `modelId?` and `providedData?: { clusterInfo }`. + The availability check is "any Copilot model". + - To do: implement `buildProvidedDataRequest(context, queryContext, clusterInfo)` in + `indexAdvisorCommands.ts`. It is the connectionless branch of `prepareOptimizationRequest`: + it uses `queryContext.executionPlan` / `collectionStats` / `indexStats` as given (all + optional except the plan), calls `fillPromptTemplate`, and builds the same three messages. + Factor the message-building tail so both branches share it. Record telemetry + `connectionless='true'`. + - _Acceptance:_ compiles. The existing behaviour is unchanged apart from the model choice. + - _Outcome:_ Completed provided-data prompt construction and shared three-message tail; added selection and connected/connectionless regression tests (verification batched at Checkpoint A). + +- [x] **WI-1: Prompt iteration without a reload** + - In `PromptTemplateService.getIndexAdvisorPromptTemplate`, don't read from or write to the + cache when a custom template path is configured. Replace the per-run info toast with + an `ext.outputChannel.trace` line (with the cache off it would fire on every run). + - Add an exported helper `getIndexAdvisorPromptSourceInfo(commandType)` → + `{ source: 'built-in' | 'custom-file'; path?: string }` (reads the setting only) for the UI. + - _Acceptance:_ editing the custom file applies on the next "Get AI Performance Insights" + click. The built-in path still uses the cache. + - _Outcome:_ Custom files bypass cache reads/writes and log only to trace; exported settings-only source info and added edit/retry/source regression tests. + +- [x] **WI-2: Model RPCs** + - A new small router `queryInsightsModels` mounted in `appRouter` (e.g. `ai: { ... }`): + `getAvailableModels` (query → `AvailableModelsResult`), `setSelectedModel` (mutation, + `{ modelId: string | null }`), and `getPromptSourceInfo` (query, `{ commandType }`). + Usable from any webview. + - _Acceptance:_ typed client calls compile from both webviews. + - _Outcome:_ Mounted connection-independent `ai` discovery/preference/prompt-source procedures with validated inputs and RPC regression tests. + +- [x] **WI-3: Shared `ModelPicker` component** + - `src/webviews/documentdb/queryInsightsShared/ModelPicker.tsx` (or similar). A Fluent + `Dropdown` modelled on cosmosdb: option = name + muted "N tokens". The utility model comes + first, then the rest in list order. Below it, a muted hint: which model is used and why + ("Saved choice" / "First available model"), and for non-utility models "May use premium + requests". It loads through `ai.getAvailableModels` and persists through + `ai.setSelectedModel`. It also shows a one-line "Prompt: built-in / custom file + ``" from `ai.getPromptSourceInfo`. + - Compact variant for the tab card, full-width variant for the wizard. + - _Outcome:_ Added compact/full-width Fluent picker, utility-first options with token counts, persisted-choice/premium hints, basename-only prompt source, and refresh/error/empty states with UI tests. + +- [x] **WI-4: Tab integration** + - `GetPerformanceInsightsCard`: add optional slots `modelPicker?: ReactNode` and + `secondaryLink?: ReactNode`. Render the picker in the actions row next to the primary + button. When the effective model isn't the utility model, the cost-disclosure line reads + "The selected model may use premium requests." instead of "No additional cost…". + - Apply the same conditional to the post-response byline in `QueryInsightsTab.tsx`. + - Add the **"Explore with your own input…"** link (wired in WI-7; until then it may call a + no-op or be hidden behind `false`). + - **Checkpoint A** (bundled verification): `npm run build`; + `npx vitest run src/services src/commands/llmEnhancedCommands src/documentdb/queryInsights src/webviews/documentdb/collectionView`. + - _Outcome:_ Integrated the compact picker and optional secondary-link slot (explorer hidden until WI-7); disclosures use the selected model before invocation and actual response identity afterward. Checkpoint A passed: build successful, 63 test files / 1,330 tests passed, zero failures; stopped before Phase B. + +### Phase B: explore without a connection (standalone wizard) + +- [x] **WI-5: Make `QueryInsightsMain` mountable outside the Collection View** (D5, F4) + - Introduce a `QueryInsightsDataSource` interface + React context + (`queryInsightsTab/QueryInsightsDataSourceContext.tsx`). It exposes: `getStage1()`, + `getStage2()`, `streamStage3(callbacks) → unsubscribe`, `executeAction(...)`, plus flags + `actionsEnabled` (index actions), `isQueryLoading`, and an identity + (`{ clusterId?, databaseName, collectionName }`). + - The default provider in `CollectionView` wraps today's tRPC calls, so the behaviour is + identical. `QueryInsightsMain` reads from the context instead of calling `trpcClient` + directly. Its pipeline state may stay on `CollectionViewContext` for the tab; the + standalone view provides its own state holder (hook / `useReducer`) via the same + adapter. Pick whichever needs fewer changes and log the choice. + - Stage 1 in standalone mode: synthesize from the explain (`queryPlanner` / + `executionStats` basics) or skip it and let Stage 2 fill the panel. Log the choice. + - With `actionsEnabled: false`, the improvement cards show only "Copy command" (no + create/drop/hide). + - _Acceptance:_ the Collection View tab behaves exactly as before (manual smoke in + Checkpoint B). + - _Outcome:_ Shared data-source context now drives the unchanged layout and pipeline; Collection View supplies the existing session calls/state, standalone cards can be copy-only. Verification deferred to B. + +- [x] **WI-6: Standalone host** + - `src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneRouter.ts`: + - `getPrefill` (query): the initial values passed through the webview config (empty, or + taken from a session when opened from the tab: query, explain plan, collStats, + indexes, db/collection names, cluster type). + - `validateInput` (query, optional): parse EJSON/relaxed JSON via the existing helpers, + detect the command type (find / aggregate / count) and extract the query from + `explain.command` / `queryPlanner.parsedQuery`. + - `getStage2({ explain, totalDocs?, filter? })`: the pure Stage 2 path (the same code + as `getQueryInsightsStage2` after explain). Factor out a shared + `analyzeExplainForStage2(explain, totalDocs, filter)` instead of copy-pasting if + it's cheap. + - `streamStage3(input)` subscription: the same event loop as + `queryInsightsEventsRouter.streamStage3` (parser → `QueryInsightsStreamEvent`), but + it calls `QueryInsightsAIService.getOptimizationRecommendationsStreaming` with + `providedData` (WI-0) and `modelId`. Factor out the loop if that's practical, + otherwise copy it (POC) and log the deviation. + - `queryInsightsStandaloneController.ts`: `openAppWebview({ webviewName: 'queryInsightsStandalone', title: 'Query Insights (POC)' })`. + Register it in `WebviewRegistry.ts` and `appRouter.ts`, and add the view name to the + `openFeedback` allow-list only if needed. + - _Outcome:_ Registered connectionless panel/router with explain/query validation, synthesized Stage 1, shared pure Stage 2 and cancellable parser-driven Stage 3; service accepts supplied data, command type and model. Wizard placeholder replaced in WI-7. + +- [x] **WI-7: Standalone wizard UI + entry points** + - `QueryInsightsStandalone.tsx`, built on the `Wizard` / `WizardStep` pattern from + `LocalQuickStart.tsx`: + 1. **Welcome:** what it does, what to paste (`explain("executionStats")` output), and + that the data is sent to the selected Copilot model. + 2. **Configure** (one page): explain plan (required; Monaco JSON editor if cheap, + otherwise `Textarea`; validated), query (pre-filled, editable), command type + (auto, overridable), collStats (optional), indexes (`getIndexes()` and/or + `$indexStats`, optional), cluster type (Azure DocumentDB / DocumentDB / other), + `ModelPicker` (full variant) with the prompt-source line. + 3. **Analyze:** mounts `QueryInsightsMain` through the WI-5 data source, so it gets the + **same layout** as the tab. Stage 3 auto-starts. "Back" keeps every input, so you + can re-run with another model or prompt. + - Command `vscode-documentdb.command.queryInsights.exploreWithOwnInput` — title + "Query Insights: Explore with Your Own Input (POC)", category "DocumentDB". It takes an + optional prefill argument. + - **Help and Feedback** tree item (`HelpAndFeedbackBranchDataProvider.ts`): + "Query Insights Explorer (POC)", icon `beaker`, runs the command. + - Tab link "Explore with your own input…" → the same command, pre-filled from the current + session (query, cached raw explain, collStats/indexes if cached or quickly fetchable, + db/collection names, cluster type). Fetch the prefill on the host through a new + `collectionView.queryInsights.getExplorePrefill` procedure. + - Respect the manifest-ID rule from `.github/copilot-instructions.md` (no single-dot + standalone IDs). Add new commands to `LAZY_COMMANDS` only if they're intentionally + unregistered. + - **Checkpoint B** (bundled verification): `npm run build`; + `npx vitest run src/services src/commands src/documentdb/queryInsights src/webviews src/tree/help-and-feedback-view`; + L2 scenario smoke of the new view, if a scenario is cheap to add (optional). + - _Outcome:_ Welcome/Configure/Analyze wizard reuses `QueryInsightsMain`, auto-starts Stage 3 and preserves inputs on Back; command, prefilled tab link and beaker Help item are wired. Checkpoint B passed: build, 130 files / 2,191 tests passed (1 skipped), plus 9/9 mocked L2 routes across three themes including copy-only actions. + +- [x] **WI-8: Wrap-up** + - Fill in the time log totals, the deviation log, and a short "How to use" section below + (settings + prompt path + entry points). + - Do not touch the feature `README.md` beyond adding this iteration to its timeline + table as "POC, not shipped". + - _Outcome:_ Added local-prompt/model/entry-point instructions, exact manual smoke steps, Phase B verification and wall-clock totals; linked this iteration as POC, not shipped. + +### Phase C: command-aware analysis and explicit preview scope (operator follow-up) + +Same per-WI commits, time logging, deviation rules and Case 1 restrictions apply. Verification +is bundled once at Checkpoint C after WI-11; do not build/test after WI-9 or WI-10. + +- [x] **WI-9: Command-type-aware Stage 2 (analysis profile)** + - Thread find/aggregate/count into the shared pure Stage 2 path. Find remains unchanged. + - Count uses `nCounted`, suppresses find selectivity/coverage/no-match/sort assumptions, and + rates index usage plus examined documents versus collection size. + - Aggregate normalizes the initial `$cursor` for metrics, plan and extended properties; + scopes the returned-document metric/diagnostics to the cursor and explains which later + pipeline stages are not rated. Never claim no sort when a pipeline `$sort` is present. + - Return profile/applicability metadata; add small aggregate (both explain formats) and count fixtures/tests. + - _Outcome:_ Added aggregate cursor normalization, pipeline-scope diagnostics/applicability, count `nCounted` extraction and count-specific index/scan-work ratings; shared helper accepts profiles while the find response remains unchanged. Added seven profile contract tests; checks deferred to C. + +- [x] **WI-10: UI applicability and preview badges** + - Standalone Analyze always shows "Feature preview". + - Non-find efficiency and plan cards show "Preview · tuned for find queries". + - Relabel aggregate/count metrics and hide or explicitly mark inapplicable cells. + Connected find tab appearance and behavior stay unchanged. + - _Outcome:_ Standalone Analyze has a Feature preview badge; non-find efficiency/plan cards carry the tuned-for-find preview. Aggregate throughput is relabelled and scope notes are visible; count-only-inapplicable cells are explicit and count plan details suppress misleading Returned counters. Added three presentation tests; checks deferred to C. + +- [x] **WI-11: Standalone correctness + Checkpoint C** + - Apply `fixupDocumentDbExplain` to supplied Azure DocumentDB plans, including cursor plans, + with synthesized `domainInfo_isAzure='true'` / `domainInfo_api='vCore'` metadata. + - Non-find static summaries tell the model which rules were skipped and why. + - Make the wizard's forward action primary, not Back; confirm the Analyze scenario lands + on Analyze. Give the standalone raw-explain button a connectionless action. + - **Checkpoint C:** `npm run build`; + `npx vitest run src/documentdb/queryInsights src/webviews/documentdb/queryInsightsStandalone src/webviews/documentdb/collectionView src/services/ai`. + - Update Phase C results, minutes, deviations and manual smoke steps. + - _Outcome:_ Applied Azure explain fixups to top-level/cursor plans, made non-find model summaries scope-aware, added a connectionless raw-plan action, corrected footer emphasis, and verified Analyze really renders Analyze. Checkpoint C passed: build, 21 files / 231 tests, and 15/15 L2 routes; real host/model smoke remains manual. + +### Phase D: wizard polish (operator follow-up) + +Operator verdict on Phase C: the wizard works but is "very basic". Bring it to the level of +`LocalQuickStart.tsx` (the gold standard) and `AtlasCredentialsView.tsx`. Same per-WI commits, +time logging, deviation rules and Case 1 restrictions apply. Verification is bundled once at the +end of WI-13 (Checkpoint D). L2 runs use the agent's **own headless Playwright**, never a shared +or interactive browser. + +- [x] **WI-12: Prompt fallback hardening (small)** + - Keep the built-in fallback through `getIndexAdvisorPromptSourceInfo` / + `PromptTemplateService`. Treat a whitespace-only path as "not configured" (trim it), so + both pickers show "Prompt: built-in" and the built-in template is used. Unit test. + - _Outcome:_ The source helper trims the setting; blank/whitespace/null paths report `built-in`, load the built-in template (cacheable, no file read, no warning), and custom paths are trimmed. Two service tests added; the existing picker test already covers the "Prompt: built-in" line. Checks deferred to D. + +- [x] **WI-13: Wizard UI revision + Checkpoint D** + - **Welcome:** `ContainerHeader` (title, subtitle, Feature preview badge), a value statement, + a "What you'll need" list (required explain output; optional collection stats / indexes, + each with a reason), copyable `explain("executionStats")` snippets for find / aggregate / + count (DocumentDB shell or mongosh) using `common.copyToClipboard`, and a privacy note. + - **Configure:** sections **Explain plan** (required), **Optional context**, **AI model & + prompt**. Live explain validation (parsed / error with position, detected command badge, + namespace, extracted query preview, "Load sample plan" for find / aggregate / count). + Optional inputs use the LocalQuickStart summary-row → editor pattern with `Collapse`. + Monospace editors. Full `ModelPicker` with cost hint and prompt line. Inline validation; + the forward button is disabled with a visible reason until the plan is valid. + - **Analyze:** compact input summary bar (command, namespace, model, prompt source; "Edit + inputs" and "Re-run", with an inline model change). Remove the nested scroll: the page + scrolls once. `QueryInsightsMain` and the Collection View tab stay unchanged. + - **Overall:** theme tokens only; focus to step heading; Announcer live regions; + keyboard-only flow; `l10n.t()` strings; "DocumentDB shell or mongosh" terminology. + - **Checkpoint D:** `npm run build`; + `npx vitest run src/webviews/documentdb/queryInsightsStandalone src/webviews/documentdb/collectionView src/webviews/documentdb/queryInsightsShared src/services`; + L2 scenarios updated/added and run headless in three themes; before/after screenshots + saved outside the repo. + - _Outcome:_ Rebuilt the wizard on LocalQuickStart patterns: `ContainerHeader` with Feature preview, sticky navigation, "What you'll need" list (Required/Optional/Prefilled), copyable DocumentDB shell or mongosh snippets, privacy note; Configure has Explain plan / Optional context / AI model & prompt sections, live non-throwing `inspectInput` validation (line/column, command badge, namespace, plan chain, extracted query, sample plans), summary-row editors with `Collapse`, and a disabled-with-reason forward action; Analyze has an input summary bar with inline compact picker, Edit inputs and Re-run, and a single page scroll. Checkpoint D passed (see Phase D verification outcome). + +### Phase E: usage transparency, richer models and slimmer JSON-input workflow + +Same per-WI commits, time logging and deviation rules apply. Verification is batched **once** +at Checkpoint E. No push, lint, prettier-fix, l10n or package; nothing under `._local/` committed. + +- [x] **WI-14: Per-run usage and credits (host + UI byline)** + - Read text and defensively decoded `usage` data parts from the shared `response.stream`. + Prefer provider token counts/total over estimates; preserve fallback, add cached/reasoning + tokens, credits and usage source. Measure credits/reported tokens on buffered and streaming + telemetry, trace one summary, and retain usage through both Stage 3 routers/reducer. + - Append known billed credits (up to two decimals, `< 0.01` for tiny positive values) and + input/output tokens to the completion byline; retain the utility disclosure. + - Test provider usage, malformed usage and missing usage with fake streamed parts. + - _Outcome:_ Shared buffered/streamed primitive consumes stable text/data parts; valid provider + usage wins, malformed/missing data falls back to estimates. Credits/reported counts reach + telemetry and both Stage 3 completions/reducer/byline; formatting and stream tests added. + Verification deferred to E. +- [x] **WI-15: Richer model info in the picker** + - Defensive F7 metadata + version on `AvailableModelInfo`. Utility first; other models + grouped by category when present. Name left, price/context and rounded tint badge right. + - Full selected-model details; compact summary + information tooltip with the same details. + Only present prices/costs/context/capabilities/identity appear. Retain choice/prompt/premium + hints; theme tokens only, all three themes. + - _Outcome:_ Added guarded runtime price/cost/category/capability metadata, utility-first + category groups, muted option metadata/tint badges and selected-model details (full panel + or compact summary/info tooltip). Added malformed/frozen metadata and picker tests; + enriched the synthetic L2 utility fixture. Checks deferred to E. + - _Lead steering:_ Mirror VS Code's **Pricing details**: native collapsible section + (full expanded, compact collapsed), right-aligned tinted cost-tier badge, **Credits per + 1M tokens**, bold right-aligned Input/Output/Cache Read/Cache Write rows, and a separate + Long context group when present. Smaller muted context/capability/identity and F9 + inherited-settings note below. Compact retains an info tooltip with noninteractive details. +- [x] **WI-16: Slim Analyze header** + - One wrapping muted facts row: Reconfigure back arrow (inputs kept), command tint badge, + monospace namespace, cluster type, model and prompt. No dropdown or Re-run; reconfigure + to change a model. Keep heading focus and single page scroll. + - _Outcome:_ Replaced the summary box/dropdown/actions with one wrapping row of muted + facts and a Reconfigure back arrow/tooltip. Reconfigure preserves state and the L2 + assertion repeats Analyze with identical inputs. Checks deferred to E. +- [x] **WI-17: Monaco for JSON inputs + Checkpoint E** + - Repo Monaco wrapper for explain (~16 lines), query/stats/indexes (~8 lines): JSON, + line numbers, no minimap, no wrapping, folding, automatic layout, VS Code theme, + accessible labels and Escape exit. Add parse-error markers at reported positions. + Adapt textarea-based scenarios to Monaco. + - **Checkpoint E:** `npm run build`; + `npx vitest run src/services src/commands/llmEnhancedCommands src/webviews/documentdb/queryInsightsStandalone src/webviews/documentdb/queryInsightsShared src/webviews/documentdb/collectionView build/verification`. + Stop on any failing `TDD:` suite; do not edit its contract. + - Own headless Playwright against the **existing** port 18080 watcher; never restart/kill it. + Run `/scenarios/run-all/queryInsightsStandalone/.js` in all three themes; + save `phase-e-` dark screenshots (marker, picker details, slim header) in the session + wizard-ui directory. If needed use gitignored local libraries and `en-US`, then remove + the libraries. Bound headless setup attempts to ~10 minutes. + - _Outcome:_ All four inputs use themed Monaco with line numbers, fixed ~16/~8-line + heights, positioned/cleared parse markers, folding/no-wrap and Escape exits. Added + editor marker/settings/focus tests and Monaco-ready scenarios. Checkpoint E passed + build, 64 files / 1,312 tests, browser harness 6 files / 79 tests and 21/21 themed L2 + routes; dark screenshots saved. See the environment accommodation below. + +### Phase F: design exploration (operator follow-up) + +The operator called the tab card's model picker (compact `ModelPicker` in the +`GetPerformanceInsightsCard` actions row) "really bad design". Before changing the card, five +options are rendered side by side with mock data in a dev-only L2 scenario, +`/queryInsightsStandalone/tab-model-options/` (config flag +`designPreview: 'tab-model-selection'`; components in +`src/webviews/documentdb/queryInsightsShared/designPreview/`). `GetPerformanceInsightsCard` and +`QueryInsightsTab` are unchanged; the operator picks first. + +- **Option A — Model pill in the actions row** (recommended): primary button + subtle + VS Code Chat-style pill "GPT-6.1 Sol [Medium cost] ⌄" opening a grouped radio menu with a cost + badge per model; prompt, Explore and Learn more in one muted footer line. +- **Option B — Split button:** the chevron holds the model radio group and "Explore with your own + input…"; "Using GPT-6.1 Sol [Medium cost] · Built-in prompt" as meta text under the row. +- **Option C — Settings footer strip:** shipped card body, then a divider and a Model / Prompt + label-value strip with a "Change" menu link and Explore on the right. +- **Option D — Native VS Code quick pick:** the card names the model; "Change model…" would open a + host `showQuickPick` (category separators, description = cost tier · context, detail = credits). + Static mock of the quick pick shown. +- **Option E — Header chip with a settings popover:** model chip in the card header opens a popover + with the model dropdown, cost and credits, context, prompt and Explore; disclaimer moves to the footer. +- **Current:** the real card + compact picker fed the same mock models, for comparison. +- _Outcome:_ Build, 34 + 79 targeted tests and all 27 `queryInsightsStandalone` L2 routes (three + themes) passed with zero harness errors; screenshots `tab-model-options-{dark,light,high-contrast}.png` + and `welcome-numbered-light.png` saved in the session `wizard-ui` folder. + +## How to use (filled in at WI-8) + +1. Keep the internal prompt outside git, for example + `/._local/prompts/query-insights-find.prompt.md`. Set the appropriate absolute file + paths in VS Code's settings: + ```json + { + "documentDB.aiAssistant.indexAdvisorFindPromptPath": "/._local/prompts/query-insights-find.prompt.md", + "documentDB.aiAssistant.indexAdvisorAggregatePromptPath": "/._local/prompts/query-insights-aggregate.prompt.md", + "documentDB.aiAssistant.indexAdvisorCountPromptPath": "/._local/prompts/query-insights-count.prompt.md" + } + ``` + Replace `` with this checkout's absolute path. Configure only the command types you + want to override; clear a setting to use the built-in template. Custom files are read on + every run, with no reload needed. The prompt must emit + `{ "educationalContent": "...", "analysis": "...", "improvements": [] }` in the existing + JSON contract; the extension does not append a format suffix. +2. Pick a Copilot model in the existing Query Insights card (compact picker), or on the + explorer's Configure page (full picker). One saved choice is shared across both surfaces. + The picker shows the prompt source/basename and premium-request warning where applicable. + Phase E adds a collapsible **Pricing details** section when metadata is available: + a server-supplied relative cost tier and per-million-token prices, not an estimate of + your bill. Thinking level and context size follow this model's VS Code Chat picker + settings. The completed run's byline shows actual billed credits when Copilot reports + them, plus known input/output tokens; missing usage falls back to estimated token counts + and never invents credits. +3. Open through any of the three entry points: + - Command Palette → **DocumentDB: Query Insights: Explore with Your Own Input (POC)**. + - **Help and Feedback → Query Insights Explorer (POC)** (beaker icon). + - Query Insights tab → **Explore with your own input…**, before requesting AI insights: + carries the session query, raw explain, optional statistics/indexes and collection identity. +4. In the explorer select **Continue**, paste the `explain("executionStats")` result, optionally + add collStats and a `getIndexes()` / `$indexStats` array, and validate/extract the query or + edit it yourself. Choose/override find, aggregate or count and the cluster type, then + **Analyze**. Stage 2 and Stage 3 start automatically; the Analyze step uses the same + two-column component as the connected tab. Recommendations offer **Copy command**, never + database-changing actions. **Back** preserves all text for another run with a changed model/prompt. + + Phase D (UI revision): the Welcome page lists what you need and has copy buttons for the + `explain("executionStats")` commands (DocumentDB shell or mongosh). Configure checks the plan + as you type (line/column on errors), shows the detected command, namespace, plan and extracted + query, and offers **Load a sample plan: find · aggregate · count**. Optional context rows + ("Not provided" / "Provided · N indexes") open their editors with the pencil button. The + forward button is **Analyze query**; while it is disabled the footer says why. On Analyze, + Phase E replaces the tall summary bar with one slim row: **Reconfigure**, command, + namespace, cluster type, model and prompt source. Select **Reconfigure** to change inputs + or model, then **Analyze query** again; everything is kept. There is no inline model + dropdown or Re-run button on Analyze. + + Configure now uses Monaco JSON editors for explain, query, collection statistics and + indexes. Explain has about 16 lines, the optional editors about 8. Line numbers match the + inline parse locations; errors also have squiggles/overview markers. Escape leaves the + editor so Tab can continue through the form. + + Phase C adds explicit preview scope: every standalone Analyze page says **Feature preview**; + aggregate/count efficiency and plan cards say **Preview · tuned for find queries**. + Aggregate counters describe the initial cursor, not final pipeline output. A folded + single-plan pipeline without a recoverable cursor boundary shows unavailable initial + returns/selectivity/rating rather than a guessed score. Count reads `nCounted` and marks + selectivity, fetch overhead and sorting inapplicable. Select **Azure DocumentDB** to apply + the same explain-counter workarounds as the connected path. **View Raw Execution Stats** + opens the supplied, corrected explain without a session. + +### Manual smoke still required (real VS Code + Copilot) + +1. Open the command from the palette with no connection. Continue, paste a real executionStats + explain, validate/extract, pick a model and Analyze. Confirm metrics, plan and streaming cards. +2. Copy a recommendation; verify clipboard contents and absence of Create/Drop/Hide actions. + Cancel an in-flight run, then retry; close the panel while streaming and confirm cancellation. +3. Back → change the model and edit the local prompt file → Analyze again. Confirm inputs + survive, the new prompt is used without reload, and the response byline names the new model. +4. Open via the Help and Feedback beaker item; confirm the same empty wizard. +5. Run a real Collection View find query. Open Query Insights, confirm the existing metrics, + right-column plan and opt-in AI behavior are unchanged, then use the explorer link and check + the prefilled query, raw explain, database/collection and available stats/indexes. +6. In the connected tab request AI recommendations, verify real index actions still work, then + refresh/change the query and switch tabs during streaming to check reset/cancel behavior. + +### Additional Phase C manual smoke (real plans, not the mocked L2 fixtures) + +1. In Configure paste a count explain with `nReturned: 0` and a COUNT stage with `nCounted: 80`, + backed by COUNT_SCAN; supply collStats with `count: 100`. Select **count**, choose a model, + Analyze. Confirm **Documents counted = 80**, no false "No matching documents" badge, an + index-aware rating, and **Not applicable to count queries** for selectivity/fetch/sort. + An explain without `nCounted` must show an unavailable count, not zero. +2. Back, clear the old query text, and paste an aggregate `stages[]` explain whose initial + `$cursor` returns 35 documents but `$group` returns one, followed by `$sort`. Select + **aggregate**, validate/extract, Analyze. Confirm **Documents passed to pipeline = 35**, + a populated cursor plan, a scope note naming `$group`/`$sort`, and no false "No" sort claim. + Query inference from explain-only data can be partial; review the editable pipeline. +3. Repeat with a single-plan aggregate containing only pushed-down match/sort, then one with + a folded GROUP. The first supports normal initial metrics; the second must not invent + initial throughput/selectivity/rating from its final `nReturned`. Remove cursor time and + leave only a last-stage estimate: it must be labelled overall/estimated, not a fast cursor. +4. Paste an Azure DocumentDB SORT→COLLSCAN explain with top-level docs/keys of 2 and a deeper + scan count of 100. Select **Azure DocumentDB** and Analyze: expect 100 documents examined and + 0 keys for COLLSCAN. **View Raw Execution Stats** must open the corrected read-only input. + Back → select **Other** → rerun: the Azure-only fixups must not apply. +5. On Configure confirm **Analyze** is the filled forward action and **Back** is secondary. + The connected find tab must have its original labels/layout/actions and no new preview badges. + Every standalone Analyze page has Feature preview; non-find cards have the two tuned-for-find badges. +6. With real Copilot, compare count/aggregate recommendations: they must receive the summary's + skipped-rule/scope explanation, not a find-only assertion that zero count output means no + matches or final grouped output is filter selectivity. Cancel/back/close during streaming + and retry with a new model/local prompt as in Phase B. + +## Time log + +Wall-clock UTC, 2026-10-20. Minutes are agent working time, including waiting on builds. + +| Step | Who | Start | End | Minutes | Notes | +| --------------------------------------------------------------------------- | ------------------------------ | ----- | ----- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Plan v1: research (QI pipeline, prompt service, cosmosdb picker) + proposal | Lead agent | 10:46 | 10:58 | 12 | | +| Plan v2: decisions, org-default research (vscode source) | Lead agent | 11:02 | 11:17 | 15 | Includes WI-0 partial (~5 min) | +| Plan v3: this file (CONTRIBUTING format, WIs) | Lead agent | 11:23 | 11:26 | 3 | | +| Phase A review (lead) | Lead agent | 11:43 | 11:45 | 2 | Diff + logs reviewed; Phase B launched | +| WI-0 | Coding agent (gpt-6.1-sol) | 11:26 | 11:28 | 2 | Includes instruction/plan reading; checks deferred to A. | +| WI-1 | Coding agent (gpt-6.1-sol) | 11:28 | 11:30 | 2 | Includes the WI-1 commit; checks deferred to A. | +| WI-2 | Coding agent (gpt-6.1-sol) | 11:30 | 11:31 | 1 | Checks deferred to A; shared RPC tests live under services/ai to join its selector. | +| WI-3 | Coding agent (gpt-6.1-sol) | 11:31 | 11:34 | 3 | Checks deferred to A. | +| WI-4 + Checkpoint A | Coding agent (gpt-6.1-sol) | 11:34 | 11:42 | 8 | Includes restoring missing dependencies and fixing new test fixtures; final build and all 1,330 targeted tests passed. | +| WI-5 | Coding agent (gpt-6.1-sol) | 11:44 | 11:46 | 2 | Shared adapter and copy-only cards; checks deferred to B. | +| WI-6 | Coding agent (gpt-6.1-sol) | 11:46 | 11:48 | 2 | Host plumbing; checks deferred to B. | +| WI-7 + Checkpoint B | Coding agent (gpt-6.1-sol) | 11:48 | 14:33 | 165 | Includes ~150 minutes waiting on integrated browser/tools (11:56–14:26); final build/tests and 9 L2 routes passed. | +| WI-8 | Coding agent (gpt-6.1-sol) | 14:33 | 14:34 | 1 | Usage, manual checks, timeline and Phase B totals. | +| WI-9 | Coding agent (gpt-6.1-sol) | 14:34 | 14:38 | 4 | Verified/corrected F6 against installed helper; checks deferred to C. | +| WI-10 | Coding agent (gpt-6.1-sol) | 14:38 | 14:41 | 3 | Preview/applicability UI; checks deferred to C. | +| WI-11 + Checkpoint C | Coding agent (gpt-6.1-sol) | 14:41 | 15:03 | 22 | Correctness/summary refinements, Checkpoint C and 15 themed L2 routes. | +| Phase C scoping (lead): find-only evaluation (F6) + steering message | Lead agent | 14:30 | 14:40 | 10 | Read ExplainPlanAnalyzer / explain-plan-helper / fixup; screenshot of the L2 page | +| Phase B+C review (lead) | Lead agent | 15:05 | 15:12 | 7 | Commits, plan log, count L2 page rendered and inspected | +| Phase D setup + WI-12 | Coding agent (claude-opus-5.5) | 15:16 | 15:21 | 5 | Read plan/wizards/skills, headless L2 driver + "before" screenshots, Phase D section, prompt trim + tests; checks deferred to D. | +| WI-13 + Checkpoint D | Coding agent (claude-opus-5.5) | 15:21 | 15:42 | 21 | Inspection RPC, wizard rewrite, scenarios, unit tests, build, Vitest, 21 headless L2 routes, screenshots, plan. | +| Phase D review (lead) | Lead agent | 15:43 | 15:47 | 4 | Screenshots reviewed; count cells shortened to "Not applicable" (full text kept in tooltip) because the long value clipped in the right column; build + 143 targeted tests passed | +| Badge style + Required tag (lead) | Lead agent | 16:22 | 16:27 | 5 | Operator feedback; build + 143 targeted tests passed; Welcome + Analyze checked in the L2 browser | +| WI-14 | Coding agent (gpt-6.1-sol) | 16:35 | 16:37 | 2 | Plan/F7/F8, stable usage data parts, telemetry and byline; checks deferred to E. | +| WI-15 | Coding agent (gpt-6.1-sol) | 16:37 | 16:38 | 1 | Rich metadata, grouped options, details panel/tooltip and tests; checks deferred to E. | +| WI-16 | Coding agent (gpt-6.1-sol) | 16:38 | 16:39 | 1 | Slim facts row and reconfiguration scenario; checks deferred to E. | +| WI-15 follow-up | Coding agent (gpt-6.1-sol) | 16:39 | 16:40 | 1 | Lead screenshot steering: VS Code-style collapsible pricing, tier badges and F9; checks deferred to E. | +| WI-17 + Checkpoint E | Coding agent (gpt-6.1-sol) | 16:40 | 16:56 | 16 | Four Monaco editors, markers/focus tests, later lead F9/F10 clarifications, checkpoint fixes, build/tests, 21 headless routes, screenshot/theme inspection, cleanup and commit. | +| Phase E style review (lead) | Lead agent | 16:58 | 17:12 | 14 | Pricing panel moved from raw SCSS (px, `'›'` text chevron, editor-background card) to Fluent tokens + `ChevronRightRegular` (VS Code-like subtle card), duplicate pricing label removed, cost values and byline token counts locale-formatted, identity line `id · family · vN`, HC stroke. Restarted the operator's stale Vite watcher (cleared `node_modules/.vite`). Headless screenshots in 3 themes; build + 149 targeted tests passed | +| Operator feedback round (lead) | Lead agent | 17:40 | 18:02 | 22 | Model section as label/value table; explicit "Not reported"; no toggle; first-Powerful default; Analyze title; no transient subtitle; new `configure-models` scenario; build + 163 unit + 79 harness tests; headless screenshots in 3 themes | +| Phase F: design exploration | Coding agent (claude-opus-5.5) | 19:06 | 19:23 | 17 | Five tab-card model-selection options + Current in one dev-only L2 scenario (`tab-model-options`); route counts 66→69 / 24→27; build, 34 + 79 tests, 27 headless routes, screenshots in three themes. | +| Phase F review + Welcome list (lead) | Lead agent | 19:04 | 19:31 | 27 | Numbered "What you'll need" (LocalQuickStart CounterBadge, title badges); briefed Phase F; per-option headless captures of the design preview for review | +| Ship Option A + cost wording (lead) | Lead agent | 19:34 | 20:02 | 28 | Compact ModelPicker → model pill + grouped menu; card meta line; no "Free" anywhere; build + 163 unit + 79 harness tests; 27/27 explorer L2 routes headless | + +## Deviation log + +| WI | Deviation | Rationale | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| WI-14 | Both routers and the reducer already forward the entire usage object, so only the intervening shared usage shapes needed extending. Client-side counting runs only for missing provider counts. | Avoids disturbing the existing streaming lifecycle or adding redundant count requests; provider totals (including reasoning/overhead) remain authoritative. | +| WI-15 | POC deliberately reads pricing properties that the shipped code avoided (see `copilotService.ts` comments); no proposal is enabled. The initial conservative shipping assumption (finalization or enabling the proposal) is superseded by the lead's F10: defensive ungated reads can ship without flags, and we must never declare `enabledApiProposals`. `version` was already in `AvailableModelInfo`. Unknown categories are grouped as Other; unknown price tiers remain visible as text. | F7 establishes plain property access but not a stable API contract; F10 distinguishes publishability from runtime-contract stability. Absent metadata hides the Pricing details section, with a regression test. | +| WI-16 | Removed the Analyze Re-run button and inline model picker at the operator's request, superseding WI-13's summary actions. | Model changes now intentionally go through Reconfigure; the reused panel's focus and scoped single-scroll rules stay intact. | +| WI-15 follow-up | Lead steering arrived after WI-15 was committed, so the requested VS Code pricing layout/F9 is a separate follow-up commit. Compact pricing can expand inline; its info tooltip repeats the same details without interactive controls. | Preserves per-WI history and keyboard-operable disclosure without putting focusable controls in a tooltip. | +| WI-15 correction | Replaced the lead's initial four-colour cost mapping with verified VS Code behaviour: neutral low/medium, warning high/very_high; unknown tiers retain a capitalized label. F10 adds the exact Marketplace/runtime distinction and hides absent Pricing details. | Follow-up lead research established CAPI supplies relative tiers, not computed token costs; sources are recorded in F9/F10 and the code comment. Folded into the WI-15 follow-up commit before final WI-17 handoff. | +| WI-17 | All four inputs use one small wrapper around the repo Monaco editor. No existing standalone scenario used textarea `fill`, so no new driver action was necessary; Configure readiness now waits for Monaco and the invalid fixture contains genuinely malformed multiline text. | Avoids an unnecessary harness API while keeping existing typed click steps and proving a visible parse marker. | +| Checkpoint E | The exact root Vitest selector does not discover browser-harness tests because they have their own config, so its existing `build/verification/browser/vitest.config.ts` was also run (6 files / 79 tests). Initial helper/test typing failures were corrected within this single checkpoint, without changing a TDD contract. | Covers the edited scenarios rather than claiming the root selector exercised them. The full root selector ultimately passed 64 files / 1,312 tests. | +| Checkpoint E (headless environment) | Chromium required seven locally extracted libraries (the six previously noted plus transitive `libwayland-server0`); locale `en-US`. The existing watcher referenced a missing/stale optimized JSON-mode chunk (504). Its cache was regenerated without restarting it; headless-only routing served the restored JSON-mode module against the watcher's existing stable Monaco API namespace. Replacing the whole optimized graph was rejected after it duplicated React/Monaco identities. | Preserved the operator's running server and avoided an interactive browser. This is a transparent test-environment accommodation, not proof that the current watcher's stale cache works unmodified; a fresh watcher is not part of this task. Local libraries and scratch drivers are removed after screenshots. | +| — | Coding started (WI-0, partial) before the operator had reviewed the plan. | Autopilot over-reach. Stopped on request; the partial work is kept and finished in WI-0. | +| WI-5 | Keep the existing `QueryInsightsState` union and transition helpers; expose pipeline + dispatch on the adapter. Standalone synthesizes Stage 1 from the supplied explain. | Avoids moving Collection View state/prefetch or duplicating the layout. The standalone holder can use ordinary `useState`; synthesizing Stage 1 preserves all existing sequencing/plan-summary behaviour instead of special-casing a skipped stage. Common URL/error/model RPCs remain shared directly; only data/session operations move behind the adapter. | +| WI-6 | The standalone subscription reuses the parser/service/event types but has a small separate streaming loop; no connected completion-event telemetry or survey activity. | Extracting the connected loop would disturb its detailed session telemetry/timing instrumentation. Copying the essential event lifecycle is lower risk for a POC; all cancellation paths still abort the model and both inputs are analyzed with one shared pure Stage 2 helper. | +| Checkpoint B | Updated the registry test's exhaustive view-name list and browser fixture/runtime/scenario tables for the new registered view. Added Welcome, Configure and Analyze fixtures in all three themes. No existing snapshots or TDD contracts changed. | The build makes these tables exhaustive over `WebviewName`; leaving them unchanged fails compilation. Browser smoke additionally caught ambiguous forward-button names and missing mutation acknowledgement values; both were corrected. The integrated runner needed a supplied timer shim (`page.waitForTimeout`) because its external execution context has no global `setTimeout`; the repository runner was not changed. | +| WI-7 | Session prefill reads optional collStats/indexStats/metadata with a 1.5-second best-effort bound per parallel call. | They are not cached on `ClusterSession`; bounded lookups provide useful prefill without blocking opening the explorer when optional metadata is unavailable. | +| WI-9 | Corrected F6's aggregate-return/time assumptions against the installed helper, normalize to the cursor before invoking it, and leave profile metadata absent on the connected find path. Count results without `nCounted` are explicitly unavailable. | The helper mixes final pipeline returns with initial cursor examination and can mutate cursor counters; retaining that behavior would make selectivity misleading. Preserving the find response shape avoids fixture/shipped-behavior churn. Missing count evidence must not become a fabricated zero. | +| WI-11 | Optimized aggregates without a separate cursor boundary suppress initial returns/selectivity/rating; missing cursor timing is unavailable or explicitly a whole-pipeline estimate, and does not produce a fast-cursor rating. | Inspecting both explain formats exposed cases where relabelling the top-level counters would still mix final output or pipeline time with initial-stage rules. Hiding unsupported conclusions is safer than guessing; small fixtures exercise these boundaries. | +| WI-11 | Count rating is an explicit preview heuristic: no document reads is excellent; indexed reads below 10% / 50% of known collection size are excellent / good; larger indexed scans are fair, while unindexed broad/unknown scans are poor. | Count does not return matching documents, so the find efficiency score cannot be reused. Index access plus scan work is the operator-requested basis; this POC heuristic is not a shipped count-rating design. | +| WI-11 | Added an optional raw-explain action to the adapter; standalone uses its own read-only JSON RPC. Inferred aggregate queries include available cursor filter/stage operators and warn that extraction can be partial. | The reused plan component still contained a hidden session-only raw-plan action. Keeping its connected fallback preserves find behavior while avoiding a broken standalone button. Explain-only input cannot always recover the exact original command. | +| Checkpoint C | Corrected only new test fixtures: the find-equivalence baseline now includes existing extended-stage enrichment, and the negative "No" assertion matches a full line rather than the prefix of "Not rated". No existing snapshots or TDD contracts changed. | These failures were test-setup/assertion mistakes, not intentional changes to find behavior. Source L2 readiness also waits for completed Analyze content, not merely profile labels that can appear before Stage 2 finishes. | +| WI-12 | No picker change needed: `ModelPicker` already renders "Prompt: built-in" for any non-custom source and its existing test covers it; the fix is the trim in `getIndexAdvisorPromptSourceInfo`, which both the pickers and `PromptTemplateService` read. | The helper is the single source of truth, so one change covers the Configure picker, the tab picker and template loading. | +| WI-13 | Added a non-throwing `inspectInput` query (plain `publicProcedure`, no telemetry) returning per-field results with 1-based line/column from the shell parser; `validateInput` stays for compatibility but the wizard no longer calls it. Analyze re-inspects once before switching steps. | Live validation runs on every debounced edit: a throwing RPC can only carry a message, cannot report several fields at once, and would emit a telemetry event per keystroke. | +| WI-13 | Analyze actions (Edit inputs, Re-run) live in the summary bar as requested; the Analyze footer carries only the copy-only note, and the indicator never navigates to Analyze (`navigable={false}`). Re-run remounts the reused panel with a new key and the currently selected model. | Duplicating the same actions in the footer would put two "back to Configure" buttons on one page. Remounting reuses the existing Stage 1→3 pipeline and cancellation without touching `QueryInsightsMain`. | +| WI-13 | Nested scroll fixed by a scoped wrapper (`.queryInsightsExplorer-analysis > .container / > .contentArea`) that removes the tab's fixed height and inner `overflow-y: scroll` only inside the wizard. Added `body { padding: 0 }` like LocalQuickStart. | `QueryInsightsMain` and the tab stylesheet are unchanged, so the Collection View keeps its own scroll area. Production inlines one stylesheet for all views, and LocalQuickStart already ships the identical body rule, so other views see no change. | +| Checkpoint D | Updated stale harness counts (`scenarios.test.ts` 42→63 routes; `harness.test.ts` registry keys now include `queryInsightsStandalone`). The view fixture's style probe moved from the removed `.queryInsightsStandalone` wrapper to `body padding-top`. | The route and key counts had been stale since Phase B (57 routes vs 42 expected); my two new scenarios change the same numbers. Not TDD suites; no snapshots changed. | +| Checkpoint D | Headless L2 used Playwright 1.54 from the local npx cache with `chromium_headless_shell-1181`; WSL lacked six system libraries and sudo needs a password, so the `.deb`s were fetched with `apt-get download` and extracted under `._local/` (gitignored, removed afterwards) via `LD_LIBRARY_PATH`. The page locale was set to `en-US`. | No interactive or shared browser was used. With the inherited POSIX locale, `navigator.language` was `en-US@posix`, which `Intl` rejects inside `formatTokenCount` and crashes the picker. That is a harness artefact (VS Code always supplies a BCP 47 tag) and was not changed in product code. | +| WI-3 | Made `formatTokenCount` browser-safe with an optional locale argument; host logging passes the VS Code locale explicitly. | The existing utility imported `vscode` at runtime, which cannot ship in a webview bundle. Reusing a pure helper avoids duplicating formatting while preserving host output. | +| Checkpoint A | Restored lockfile dependencies with `npm ci` after the initial commands could not start due to missing packages. | This worktree had no installed dependency set; no dependency manifests or lockfile changed. | +| Checkpoint A | The new picker selection test replaces only Fluent's floating dropdown/option layer with a native test stand-in. | Opening the real popup in jsdom (no layout engine) timed out; picker state, RPC calls, ordering, token labels, hints, and disclosure tests remain exercised. Also isolated the new host test's cancellation-error mock. No existing snapshots or TDD contracts changed. | +| Operator feedback (16:22) | Welcome: the explain output always shows **Required**; when supplied, a green "Prefilled from your session" note appears below it instead of replacing the tag. All wizard and preview badges use the house style `appearance="tint" shape="rounded" size="small"` (as in the Indexes tab and the plan summary). | "Prefilled" replacing "Required" read like a stale state; the rounded-square tint badge is the convention across the other webviews. | +| Phase E lead review | The Pricing details panel was restyled after Phase E; the operator's `watch:views` was restarted with a cleared dependency cache. | Phase E's panel used hand-rolled CSS outside the house style. The running watcher's pre-bundled Monaco chunk was stale (504 Outdated Optimize Dep), so Configure never loaded in the operator's browser; restart was the only fix because the dev origin is pinned to `localhost:18080`. | +| Operator feedback (17:40) | (1) The wizard's model section is now a label/value table matching "Optional context" (Copilot model · Cost · Context window · Capabilities · Model ID · Prompt), with no collapsible: the `
` toggle did not work reliably and hid the fact that pricing was missing. Every row is always shown; absent data reads "Not reported for this model". Costs are a small grid (Standard / Long context × Input / Output / Cache read / Cache write). The compact tab variant keeps one line plus the info tooltip. (2) **Default model** with no valid saved choice = first non-utility model whose API `category` is `powerful` (F7), else the first model; the reason (`saved` / `first-powerful` / `first-available`) is returned by the host and shown. (3) The Analyze step title is "Query Insights analysis results", with no transient "Analyzing…" subtitle. | The operator saw pricing rendered for a different model and could not tell that the selected model reported none; the collapsible added a non-working control; `powerful` is the same category VS Code's own picker groups by. | +| Phase F | The design preview is a scenario of the existing `queryInsightsStandalone` view (config flag), not a new registered view; preview strings are not localized; `playwright-core` was installed under gitignored `._local/pw` instead of `/tmp/pw` (the agent runtime forbids `/tmp`), then removed with the extracted Chromium libraries. Fluent caps menu items at 290px, so the menus show only the cost badge next to the name (context moves to tooltips/details). | Avoids touching the registry and its exhaustive harness tables; the preview is throwaway once the operator picks. | +| Operator decision (19:34): Option A | The Query Insights tab card ships Option A: primary button + a subtle model pill (name + cost badge) opening a menu grouped Utility / Powerful / Versatile / Lightweight / Other with a cost badge per model; one muted meta line (prompt source · Explore with your own input… · Learn more); the existing cost footnote stays. The compact `ModelPicker` _is_ the pill (`onPromptLabelChange` feeds the card's meta line). **No "Free" label anywhere** (pill, menu, option meta, display name `(utility, free)` → `(utility)`, wizard Cost row): the utility model gets no cost badge, and the footnote / wizard Cost row use the original careful wording "No additional cost for most GitHub Copilot subscribers." | Operator: "Free" over-promises; the footnote wording was deliberately careful. Option A matches VS Code's chat model pill and removes the duplicated premium-request text and the broken button row. | + +## Phase A verification outcome + +- Case 1 only: `npm run build` passed; the exact Checkpoint A Vitest selector passed + **63 files, 1,330 tests, zero failures** (final run 2026-10-20, 11:41 UTC). +- Total Phase A coding/checkpoint time: **16 minutes** (WI-0 2 + WI-1 2 + WI-2 1 + WI-3 3 + WI-4 8). +- No push; no lint, formatting, localization generation, packaging, live model request, or manual UI smoke performed. +- At the Phase A handoff, Phase B remained untouched and the standalone link was hidden; + Phase B is now implemented as recorded below. + +## Phase B verification outcome + +- Case 1 only: final `npm run build` passed (2026-10-20, 14:33 UTC). +- Exact Checkpoint B Vitest selector passed: **130 files, 2,191 passed, 1 skipped, zero failures** + (final test run 14:31 UTC). No TDD suite failed; no snapshots were changed. +- Mocked source L2 smoke passed **9/9 routes**: Welcome, Configure and Analyze in dark, light + and high-contrast. Analyze rendered the shared left/right-column layout and the copy-only + recommendation; clipboard RPC assertions passed. Back preserved supplied inputs without errors. +- Real host/Copilot/manual Collection View smoke remains outstanding; see the exact steps above. + No live model request was sent. No lint, formatting, localization generation, packaging, extra + `node build/verification/...` checks or pushes were performed. +- Total Phase B elapsed time: **170 minutes** (WI-5 2 + WI-6 2 + WI-7/checkpoint 165 + WI-8 1). + This includes approximately 150 minutes of integrated browser/tool waiting, not active coding. +- Total recorded coding/checkpoint time for Phases A + B: **186 minutes** (16 + 170). + +## Phase C verification outcome + +- Case 1 only: final `npm run build` passed (2026-10-20, 15:00 UTC). +- Exact Checkpoint C selector passed: **21 files, 231 tests, zero failures/skips** + (final run started 14:59 UTC). The find-equivalence test includes its existing extended-stage + enrichment; all existing find rating, advisory, transformation and summary tests passed. +- Mocked source L2 smoke passed **15/15 routes**, zero harness/late errors: Welcome, Configure, + Analyze/find, Analyze/aggregate and Analyze/count in dark, light and high-contrast. + Clipboard and standalone raw-explain RPC assertions passed. Readiness requires actual Analyze + content, including completed recommendation cards, rather than a Configure/loading state. +- Explicitly checked `/queryInsightsStandalone/analyze/dark` after Continue → Analyze: the active + heading was Query Insights, Feature preview was visible, readiness was true and no errors were + recorded. The browser was left on Analyze, not on the Back-persistence smoke state. +- Explicitly measured Configure footer styles in dark mode: Analyze's filled background was + `rgb(0, 103, 184)` / white text, versus Back's secondary `rgb(31, 31, 31)` / neutral text. +- The dev server was stopped. No live database/model request, installed-extension smoke, lint, + formatting, localization generation, packaging, extra verification scripts or push was performed. + The exact real-plan/real-Copilot smoke steps remain above; mocked UI fixtures are not backend proof. +- Phase C time: **29 minutes** (WI-9 4 + WI-10 3 + WI-11/checkpoint 22). +- This task's Phases B + C total: **199 elapsed minutes**, including approximately 150 minutes + of integrated-browser/tool waiting in Phase B (approximately **49 minutes excluding that wait**). + All recorded coding/checkpoint time including the earlier Phase A: **215 minutes**. + +## Phase D verification outcome + +- Case 1 only: `npm run build` passed (2026-10-20, 15:38 UTC). +- Exact Checkpoint D selector + (`npx vitest run src/webviews/documentdb/queryInsightsStandalone src/webviews/documentdb/collectionView src/webviews/documentdb/queryInsightsShared src/services`) + passed: **60 files, 1,231 tests, zero failures**. No TDD suite failed; no snapshots changed. + Because the L2 scenario tables changed, the harness's own unit tests + (`vitest run --config build/verification/browser/vitest.config.ts`) also ran: **6 files, 79 tests** + after the stale counts were corrected. +- Headless L2 (own Playwright, no shared browser): **21/21 routes passed** in dark, light and + high-contrast, with zero harness errors: `default`, `configure`, `configure-invalid` (new), + `configure-context` (new), `analyze`, `analyze-aggregate`, `analyze-count`. Assertions passed for + Welcome snippet copy, copy-only recommendation, connectionless raw explain, and Re-run (Stage 2 + called again with identical inputs, recommendations streamed again). +- Keyboard-only run: Tab reaches the step list, each copy button and Continue; Enter moves focus + to the Configure heading; Tab then reaches the explain editor, the three sample links, command + type, Edit query, the four row editors, the model picker and **Analyze query**; Enter lands on + the Analyze heading. At 900 × 600 the Analyze page has exactly one scroll container (the + wizard body); before, the reused panel added its own `overflow-y: scroll` area. +- Light-theme review caught and fixed an invisible "Optional" badge (`subtle` outline) and two + privacy-note sentences that ran together. +- Before/after dark screenshots: `~/.copilot/session-state//files/wizard-ui/` + (`before-{default,configure,analyze}-dark.png`, `after-{default,configure,configure-invalid,configure-context,analyze,analyze-count}-dark.png`), not in the repo. +- Dev server stopped. No lint, formatting, localization generation, packaging, live model + request or push. The real-Copilot/manual smoke steps above remain outstanding. +- Phase D time: **26 minutes** (setup + WI-12 5, WI-13 + checkpoint 21). + +## Phase E verification outcome + +- Case 1 only; the only verification checkpoint was E. Final `npm run build` passed + (2026-10-20, 16:53 UTC). Initial attempts caught the new usage helper's class syntax, + an empty-array union in token counting, and the telemetry test's optional-number type; + all were fixed inside this checkpoint. +- Exact requested root selector passed **64 files / 1,312 tests**, zero failures/skips + (final run started 16:51 UTC). The browser harness's separate config additionally + passed **6 files / 79 tests**. No TDD contract failed or changed; no snapshots changed. +- Own headless Playwright passed **21/21 routes** across dark, light and high-contrast: + Welcome, Configure, invalid Configure, optional context, find/aggregate/count Analyze. + Final reports had zero harness errors and all clipboard/raw-explain/reconfiguration + assertions passed. The existing watcher was never restarted or stopped (still HTTP 200). + See the explicit stale-optimizer/headless accommodation in the Deviation log. +- Visually inspected the error marker, dark details and Analyze screenshots, plus + light/high-contrast model details. The invalid explain shows a line-3 squiggle and overview + marker as well as the inline position. Escape moved focus to **Outside Explain output + editor**. Analyze has zero dropdowns/Re-run buttons; at 900×600 it has exactly one + scrolling container. The slim row remains one line at 1440 pixels. +- Example completed fixture byline: + **Powered by Copilot utility via GitHub Copilot · 0.0s · 0 credits · 1200 in / 350 out tokens**. + Its utility-model disclosure/learn-more line remains above it. Tiny positive credits + display **< 0.01 credits**, and unknown credits are omitted. +- Example synthetic picker: **Copilot utility**, **Free**, **Low cost**, **Credits per 1M + tokens**, Input **0**, Output **0**, Cache Read **0**; context **64,000**, tools supported, + no image input, family/version/id, inherited Chat-settings note, choice and prompt lines. + Full pricing is initially expanded; compact pricing is collapsed with an info tooltip; + absent optional prices hide Pricing details. +- Dark screenshots (outside git), under + `/home/tnaum/.copilot/session-state/16353c9f-1823-4dfb-8b3a-7a9de94cf764/files/wizard-ui/`: + `phase-e-configure-error-dark.png`, `phase-e-model-details-dark.png`, + `phase-e-analyze-dark.png`. +- Local extracted browser libraries, `.deb`s, profiles, scratch drivers/logs and extra theme + captures were removed. No push, lint, prettier-fix, l10n, package, live Copilot/database + call or installed-extension smoke. Real-Copilot/manual checks remain outstanding. +- **Phase E total: 21 minutes** (WI-14 2 + WI-15 1 + WI-16 1 + WI-15 follow-up 1 + + WI-17/Checkpoint E 16), 16:35–16:56 UTC. + +## Lead review notes (2026-10-20, 15:12 UTC) + +- **Time planning takeaway:** coding work for WI-0..WI-11 was about **65 agent-minutes** + (16 A + ~20 B + 29 C). The ~150-minute gap in WI-7 was the agent blocked on the integrated + browser tool, not coding. Next time, run L2 scenarios through the agent's own Playwright, or + share the browser page up front. +- Lead-side L2 check: `/queryInsightsStandalone/analyze-count/dark` renders "Feature preview", + "Preview · tuned for find queries" on both right-column cards, "Not applicable to count + queries" for Selectivity / Fetch Overhead / In-Memory Sort, "Documents counted 80", and a + `COUNT_SCAN → COUNT` plan. (The `run-all.js` helper can't run inside the lead's browser tool: + `setTimeout is not defined` in that sandbox, which is a tooling limit and not a product bug.) +- **Open follow-ups (not done):** + - ~~The Analyze step has a nested scroll area (the panel's own scrollbar inside the wizard page).~~ + Fixed in WI-13. + - Manual smoke with a real connection, real Copilot models and the local prompt (steps above). + +## Phase G: standard Fluent model picker, wizard polish (2026-10-21) + +Coding agent, 09:24–09:41 UTC, **17 minutes** (reading, implementation, build/tests, 27 headless +L2 routes, screenshots, plan, commit). + +- `ModelPicker` has one layout for both hosts: a Fluent `Dropdown` (`aria-label` "Copilot model"), + utility model first without a badge, then `OptionGroup`s Powerful / Versatile / Lightweight / Other + with tint/rounded/small cost `Badge`s (high/very high = warning), plus a subtle info `Button` + ("Model details") opening a `Popover` (`openOnHover`, `withArrow`, small) with a label/value list: + cost, credits per 1M tokens, context window, capabilities, model ID, prompt, selection reason. + The pill (`Menu`/`MenuItemRadio`), the `Tooltip`, the credits grid and the wizard details table are + gone; `compact` became `size` (card `small`, wizard `medium`). The list is refetched silently on + open (and on window focus), so lazily registered Copilot models appear without disabling the list. +- Wizard model section = dropdown + popover + prompt meta line + cost note ("No additional cost for + most GitHub Copilot subscribers." / "The selected model may use premium requests."). +- Welcome "What you'll need" titles are semibold. JSON editors: `glyphMargin: false`, + `folding: false`, `lineNumbersMinChars: 3`, `lineDecorationsWidth: 6` (gutter 29 px); parse errors + still show as squiggles plus the inline position. +- Verification: build; 163 targeted unit tests; 79 harness tests; 27/27 `queryInsightsStandalone` + L2 routes headless (zero errors/failed assertions). Screenshots `phase-g-*.png` in the session + `wizard-ui` folder (dropdown open and scrolled, popover in dark/light, tab card, Welcome, editors). +- **Deviations:** (1) Fluent's height `autoSize` replaced any CSS cap with all remaining webview + height and, for 17 models, placed the list _beside_ the trigger, covering it. The listbox now has + a CSS cap `min(360px, 45vh)`, width-only `autoSize`, `fallbackPositions: ['above-start']` and no + trigger-width matching (names + badges fit). The popover autoSizes height only so its 360 px width + cap holds. (2) `Popover` chosen over `InfoLabel`: `InfoLabel` opens on click only and carries a + visible label the card does not have. (3) Credits are text lines (parts never split) instead of + the grid table. (4) L2: `configure` no longer expects "Model ID" (now in the popover); + `configure-models` clicks "Model details" and has 14 extra models to prove scrolling. The design + preview's "Shipped" section was renamed. (5) `/tmp/pw` was absent and `/tmp` is off-limits for + this agent, so `playwright-core@1.54` and the browser libraries lived in `._local/` (removed after). + +## Phase H: live card-control comparisons (2026-10-21) + +14:08–14:19 UTC, approximately **11 minutes** including implementation, browser interaction, +build and focused tests. Operator requested experiments in the actual Collection View panel, +not another set of hand-crafted mock cards. No winning presentation has been selected. + +- The Stage 3 request area temporarily renders six copies of the real AI Performance Insights + card, labelled A–F: model label + subtle Change button; outline CompoundButton with the model + as secondary content; outline Button with model name and chevron; Dropdown with explicit + outline, filled-darker and underline appearances. +- Button variants open a standard Fluent Menu with grouped MenuItemRadio choices. Dropdown + variants keep the standard Fluent options. Every variant retains the model-details Popover. + Both types of list are height-capped and scrollable. +- One ModelPicker owns model fetching, saved selection, prompt source and errors for all six + presentations. Changing a model updates every card; the analysis buttons all invoke the + existing Stage 3 handler. Rendering the comparisons does not send any model requests. + The wizard Configure picker is unchanged. +- The existing `tab-model-options` preview also uses this same comparison component at the + bottom, with fixture models and a no-op analysis action. The installed/live tab uses the real + RPCs and analysis pipeline, not those fixtures. +- Light-theme browser measurements: outline Dropdown remains white, filled-darker is light + grey, underline is transparent. Outline Button and CompoundButton are transparent. These + are Fluent appearances, with no custom background overrides. +- Case 1 verification: `npm run build`; **152 targeted tests**; **79 scenario-harness tests**. + Interactive browser smoke in light, dark and high-contrast confirmed all six cards, menu and + dropdown choices, shared selection updates, and keyboard Enter/Escape with focus restored + to the CompoundButton. Dark model-details surface measured 359 px client and scroll width + (no horizontal content overflow). +- No real Copilot request, live-database smoke, full 27-route replay, lint, formatting, + localization generation, packaging or push. Quick/Advanced remains a proposal only. + +## Phase I: select F for the live card (2026-10-21) + +14:38–14:44 UTC, approximately **6 minutes** including investigation, implementation, build, +focused tests and browser smoke. + +- Operator chose F: one real AI Performance Insights card with a Fluent underline Dropdown. + A decorative Fluent Bot icon precedes the selected name while the list is closed. The + existing info Popover remains. Removed the temporary six-card comparison component and + its menu/CompoundButton alternatives from the live picker. +- Wizard Configure retains its previous small outlined Dropdown, visible Copilot model + label and info button; no model icon or underline appearance there. +- Investigated model identity: live hosts share `ai.getAvailableModels`; no Fast-specific + filter or name rewriting exists. Names come from the provider; deduplication uses IDs, + not names. Added service and UI regressions for Fast IDs, missing category metadata and + duplicate display names. Duplicate names now show model IDs in the options for clarity. +- The old design-preview options used a fixed five-model catalog, while wizard scenarios + use other fixture catalogs. Removed the old mock option gallery from the browser route; + it now shows only the selected real card component and explicitly identifies its data as + fixtures. Added a category-less Fast fixture to exercise the Other group. This is not + evidence about availability in the operator's live Copilot catalog. +- Case 1: build passed; **169 targeted tests** and **79 harness tests** passed. Browser + smoke confirmed one card, transparent underline trigger, icon removed on open/restored + on Escape, Fast option visible, and the wizard still outlined/white with no model icon. + No live Copilot request, full route replay, lint, formatting, localization generation, + packaging or push. + +## Phase J: move model controls into the disclosure footer (2026-10-21) + +16:38–16:41 UTC, approximately **3 minutes** including implementation, build, focused tests +and browser checks. + +- Operator requested the existing underline model picker and cost-info button on the bottom + row, followed by the existing cost disclosure and utility-model link, without the redundant + decorative info icon. The primary action and prompt/explore/learn-more line stay above. + Wizard Configure is unchanged. +- The footer aligns controls and text centrally and wraps on narrow cards. During loading, + the picker remains hidden while the cost disclosure stays visible, preserving prior behavior. +- Case 1: build and **16 focused card/picker tests** passed. Browser checks confirmed the + cost Popover still opens, utility selection updates the disclosure, and the 1440 px and + 600 px layouts have no horizontal footer overflow. No live AI request, full suite, lint, + formatting, localization generation, packaging or push. + +## Phase K: configuration before the action; prompt source to the output channel (2026-10-22) + +Supersedes Phase J's layout after an operator UX review of the tab card. + +- **Order follows the VS Code Chat input: configuration, then the action.** The tab card's + actions row is now model picker (underline, `size="medium"` to match the button height) → ⓘ + details → **Get AI Performance Insights**. Phase J had the picker on a separate bottom row, after + the button, so users often found the model choice only after running. +- **One muted footer line** replaces the meta line and the disclosure row: cost note (with the + utility-model link when applicable) · "AI responses may be inaccurate" (moved out of the + header) · Explore with your own input… · **Learn more about AI Performance Insights**. The + general link names its target instead of reading as "learn more about the prompt", and it is + no longer disabled when no model is available. +- **Prompt source is no longer shown in any UI** (operator: a leftover from prototyping; not + needed). Removed from the tab card, the model-details popover, the Explorer's Configure section + (renamed "AI model") and the Analyze summary bar. The `ai.getPromptSourceInfo` RPC and the + `ModelPicker` `commandType` / `onPromptLabelChange` props are gone. Instead + `PromptTemplateService` traces the source on **every** index-advisor request (custom file, + built-in with its `resource-file`/`inline-fallback` source, cached, or built-in after a failed + custom read) to `ext.outputChannel` at trace level. +- Case 1: build passed; focused tests (prompt service, router, picker, card, tab, explorer) and + the browser scenario/harness tests passed. Visual check of `tab-model-options` (light) for both + premium and utility models. l10n not regenerated (draft). + +## Phase L: Explorer Configure review fixes; AI disclaimer said once (2026-10-22) + +Operator-approved fixes from the UX review of the Explorer wizard. + +- **Model picker in the Configure footer, before Analyze query** (same order as the tab card): + picker (underline, medium) → ⓘ details → **Analyze query** → Back. The "AI model" section is gone, + with its decorative ⓘ cost line and the "Your choice is saved and shared…" sentence (operator: + noise; nobody cares). The footer note joins the send/blocked reason, the cost note and "AI + responses may be inaccurate" with `·`. +- **One term, one marker:** the section is "Explain output" with the Required badge; the duplicate + field label and asterisk are gone, and validation/announcer strings say "explain output". +- **Explain editors auto-size** with the content (120–400 px; compact 96–240 px) and wrap long + lines; one-line plans no longer need horizontal scrolling. +- **Namespace shown once:** the "Detected from the explain output" box no longer has a Namespace + fact; the Optional context row shows the value and whether it came from the explain output. +- **Prefilled explain output opens on Configure;** Welcome stays reachable from the step list and + Back. The browser fixture is now a fresh open (Welcome); scenarios add `queryInsightsSampleExplain`. +- **"AI responses may be inaccurate" is said once:** removed from every AI card header (MarkdownCard, + MarkdownCardEx, ImprovementCard, ImprovementCardShell; `showAiDisclaimer` prop removed). It is + stated where the user prompts (tab card footer, Explorer Configure footer) and once under the + results in the "Powered by" byline, in both the tab and the Explorer. +- Case 1: build passed; 72 focused unit tests and 79 browser harness tests passed; L2 explorer + routes ready in all three themes (one high-contrast route timed out under the agent's + substitute timer; driven manually it is ready with no harness errors). l10n not regenerated. diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index fd4d37de0..7ae47e75e 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -40,10 +40,11 @@ "[Copilot] countTokens failed; usage metric will be omitted: {0}": "[Copilot] countTokens failed; usage metric will be omitted: {0}", "[Copilot] First token received {0}ms after send ({1}ms after request accepted)": "[Copilot] First token received {0}ms after send ({1}ms after request accepted)", "[Copilot] Request accepted by model after {0}ms; awaiting first token…": "[Copilot] Request accepted by model after {0}ms; awaiting first token…", + "[Copilot] Selected model ({0}): {1}": "[Copilot] Selected model ({0}): {1}", "[Copilot] Selected model metadata: {0}": "[Copilot] Selected model metadata: {0}", "[Copilot] Selected model own enumerable properties: {0}": "[Copilot] Selected model own enumerable properties: {0}", "[Copilot] Selected utility model: {0}": "[Copilot] Selected utility model: {0}", - "[Copilot] Tokens: prompt={0}, response={1}, total={2}, context={3}, utilization={4}%": "[Copilot] Tokens: prompt={0}, response={1}, total={2}, context={3}, utilization={4}%", + "[Copilot] Usage ({5}): prompt={0}, response={1}, total={2}, context={3}, utilization={4}%, credits={6}": "[Copilot] Usage ({5}): prompt={0}, response={1}, total={2}, context={3}, utilization={4}%, credits={6}", "[Copilot] Utility model \"{0}\" is not available from vendor \"copilot\". Aborting request to avoid charging the user for a billed picker model.": "[Copilot] Utility model \"{0}\" is not available from vendor \"copilot\". Aborting request to avoid charging the user for a billed picker model.", "[CopyPasteTask] Index copy completed: {0} created, {1} skipped, {2} conflicts, {3} renamed.": "[CopyPasteTask] Index copy completed: {0} created, {1} skipped, {2} conflicts, {3} renamed.", "[CopyPasteTask] Index copy progress: {0}/{1} ({2}).": "[CopyPasteTask] Index copy progress: {0}/{1} ({2}).", @@ -117,7 +118,9 @@ "[Query Insights Stage 1] Query Insights is not supported on Azure Cosmos DB for MongoDB (RU) clusters.": "[Query Insights Stage 1] Query Insights is not supported on Azure Cosmos DB for MongoDB (RU) clusters.", "[Query Insights Stage 1] Started for {db}.{collection}": "[Query Insights Stage 1] Started for {db}.{collection}", "[Query Insights Stage 1] Using debug data file": "[Query Insights Stage 1] Using debug data file", + "[Query Insights Stage 2] Analysis of the execution plan failed: {error}": "[Query Insights Stage 2] Analysis of the execution plan failed: {error}", "[Query Insights Stage 2] Completed: execTime={time}ms, returned={ret}, examined={ex}, ratio={ratio}": "[Query Insights Stage 2] Completed: execTime={time}ms, returned={ret}, examined={ex}, ratio={ratio}", + "[Query Insights Stage 2] Execution plan that failed analysis:\n{plan}": "[Query Insights Stage 2] Execution plan that failed analysis:\n{plan}", "[Query Insights Stage 2] explain completed in {ms}ms": "[Query Insights Stage 2] explain completed in {ms}ms", "[Query Insights Stage 2] Query execution failed: {error}": "[Query Insights Stage 2] Query execution failed: {error}", "[Query Insights Stage 2] Started for {db}.{collection}": "[Query Insights Stage 2] Started for {db}.{collection}", @@ -125,6 +128,7 @@ "[Query Insights Stage 3 stream] Completed: {count} recommendations, {chars} chars in {ms}ms (requestKey: {key})": "[Query Insights Stage 3 stream] Completed: {count} recommendations, {chars} chars in {ms}ms (requestKey: {key})", "[Query Insights Stage 3 stream] Failed to build static analysis summary (requestKey: {key}): {error}": "[Query Insights Stage 3 stream] Failed to build static analysis summary (requestKey: {key}): {error}", "[Query Insights Stage 3 stream] Started for {db}.{collection} (requestKey: {key})": "[Query Insights Stage 3 stream] Started for {db}.{collection} (requestKey: {key})", + "[Query Insights] Failed to list language models: {0}": "[Query Insights] Failed to list language models: {0}", "[Reader] Counting documents in {0}.{1}": "[Reader] Counting documents in {0}.{1}", "[Reader] Document count result: {0} documents": "[Reader] Document count result: {0} documents", "[SchemaStore] Clearing schema cache: {0} collections, {1} documents, {2} fields": "[SchemaStore] Clearing schema cache: {0} collections, {1} documents, {2} fields", @@ -183,16 +187,22 @@ "{0} · {1}": "{0} · {1}", "{0} ({1})": "{0} ({1})", "{0} (Emulator)": "{0} (Emulator)", + "{0} (utility)": "{0} (utility)", "{0} {1}": "{0} {1}", "{0} +{1} more": "{0} +{1} more", "{0} clusters": "{0} clusters", "{0} collections": "{0} collections", "{0} completed successfully": "{0} completed successfully", "{0} connections": "{0} connections", + "{0} cost": "{0} cost", + "{0} counted": "{0} counted", "{0} created": "{0} created", "{0} credentials need attention:": "{0} credentials need attention:", + "{0} credits": "{0} credits", "{0} failed: {1}": "{0} failed: {1}", "{0} file(s) were ignored because they do not match the \"*.json\" pattern.": "{0} file(s) were ignored because they do not match the \"*.json\" pattern.", + "{0} in / {1} out": "{0} in / {1} out", + "{0} in / {1} out tokens": "{0} in / {1} out tokens", "{0} Index Definition": "{0} Index Definition", "{0} indexes copied. Document copy will begin shortly...": "{0} indexes copied. Document copy will begin shortly...", "{0} indexes created, {1} equivalent indexes skipped, {2} option conflicts skipped, {3} renamed.": "{0} indexes created, {1} equivalent indexes skipped, {2} option conflicts skipped, {3} renamed.", @@ -208,8 +218,10 @@ "{0} of {1} indexes will be copied.": "{0} of {1} indexes will be copied.", "{0} operations": "{0} operations", "{0} operations since {1}": "{0} operations since {1}", + "{0} passed to pipeline": "{0} passed to pipeline", "{0} processed": "{0} processed", "{0} replaced": "{0} replaced", + "{0} returned": "{0} returned", "{0} seconds": "{0} seconds", "{0} skipped": "{0} skipped", "{0} stage failed": "{0} stage failed", @@ -217,9 +229,11 @@ "{0} task(s) are using connections being moved. Check the Output panel for details.": "{0} task(s) are using connections being moved. Check the Output panel for details.", "{0} task(s) are using connections in this folder. Check the Output panel for details.": "{0} task(s) are using connections in this folder. Check the Output panel for details.", "{0} tenants available ({1} signed in)": "{0} tenants available ({1} signed in)", + "{0} tokens": "{0} tokens", "{0} tuning": "{0} tuning", "{0} was stopped": "{0} was stopped", "{0}, {1}": "{0}, {1}", + "{0}:": "{0}:", "{0}: completed after timeout in {1} ms; {2}. Result is not applied to the picker.": "{0}: completed after timeout in {1} ms; {2}. Result is not applied to the picker.", "{0}: completed in {1} ms; {2}.": "{0}: completed in {1} ms; {2}.", "{0}: failed after timeout in {1} ms; {2}.": "{0}: failed after timeout in {1} ms; {2}.", @@ -289,6 +303,7 @@ "A folder with this name already exists at this level": "A folder with this name already exists at this level", "A kubeconfig source for this file already exists.": "A kubeconfig source for this file already exists.", "A kubeconfig source with identical YAML already exists.": "A kubeconfig source with identical YAML already exists.", + "A later pipeline $sort is present. The initial cursor plan cannot determine its memory or disk usage.": "A later pipeline $sort is present. The initial cursor plan cannot determine its memory or disk usage.", "A local DocumentDB instance, set up for you. No configuration required.": "A local DocumentDB instance, set up for you. No configuration required.", "A new connection will be added to your Connections View.": "A new connection will be added to your Connections View.", "A password with right-to-left letters, such as Arabic or Hebrew, must start and end with one and can't also contain left-to-right letters.": "A password with right-to-left letters, such as Arabic or Hebrew, must start and end with one and can't also contain left-to-right letters.", @@ -354,11 +369,20 @@ "Always upload": "Always upload", "An element with the following id already exists: {id}": "An element with the following id already exists: {id}", "An error has occurred. Check output window for more details.": "An error has occurred. Check output window for more details.", + "An execution plan is required for connectionless query optimization.": "An execution plan is required for connectionless query optimization.", "An index on {0} would allow direct lookup of matching documents and eliminate full collection scans.": "An index on {0} would allow direct lookup of matching documents and eliminate full collection scans.", "An item with id \"{0}\" already exists for workspace \"{1}\".": "An item with id \"{0}\" already exists for workspace \"{1}\".", "An unexpected error occurred": "An unexpected error occurred", "An unknown error occurred while inserting documents.": "An unknown error occurred while inserting documents.", + "Analysis complete. Recommendations are ready.": "Analysis complete. Recommendations are ready.", + "Analysis inputs": "Analysis inputs", + "Analyze": "Analyze", + "Analyze a query plan with GitHub Copilot, without connecting to a database.": "Analyze a query plan with GitHub Copilot, without connecting to a database.", + "Analyze query": "Analyze query", + "Analyze query sends your inputs to {0}.": "Analyze query sends your inputs to {0}.", + "Analyze query sends your inputs to the selected model.": "Analyze query sends your inputs to the selected model.", "Analyzing folder contents…": "Analyzing folder contents…", + "Analyzing the explain plan.": "Analyzing the explain plan.", "Analyzing…": "Analyzing…", "and select your **organization**.": "and select your **organization**.", "API": "API", @@ -405,6 +429,7 @@ "Authentication: {0}": "Authentication: {0}", "Authentication: {0} | Database: {1}": "Authentication: {0} | Database: {1}", "Auto-deletes documents after a set age.": "Auto-deletes documents after a set age.", + "Auto-detect ({0})": "Auto-detect ({0})", "Availability": "Availability", "Azure account added successfully.": "Azure account added successfully.", "Azure account management failed: {0}": "Azure account management failed: {0}", @@ -416,6 +441,7 @@ "Azure Cosmos DB for MongoDB (RU) Emulator": "Azure Cosmos DB for MongoDB (RU) Emulator", "Azure discovery filters applied successfully.": "Azure discovery filters applied successfully.", "Azure DocumentDB": "Azure DocumentDB", + "Azure DocumentDB corrects known differences in its explain counters before rating.": "Azure DocumentDB corrects known differences in its explain counters before rating.", "Azure region reported in the cluster’s resource metadata.": "Azure region reported in the cluster’s resource metadata.", "Azure resource": "Azure resource", "Azure Service Discovery": "Azure Service Discovery", @@ -433,12 +459,14 @@ "Back": "Back", "Back to account selection": "Back to account selection", "Back to authentication method selection": "Back to authentication method selection", + "Back to Configure (inputs are kept)": "Back to Configure (inputs are kept)", "Back to Create Index": "Back to Create Index", "Back to Databases": "Back to Databases", "Back to Microsoft Entra ID identity choices": "Back to Microsoft Entra ID identity choices", "Back to tenant selection": "Back to tenant selection", "Back to the list": "Back to the list", "Balanced speed and recall for most workloads.": "Balanced speed and recall for most workloads.", + "below {0}%": "below {0}%", "Bitmap index": "Bitmap index", "Bitmap index detected: typically used for low-cardinality fields": "Bitmap index detected: typically used for low-cardinality fields", "Build candidates (efConstruction)": "Build candidates (efConstruction)", @@ -447,6 +475,8 @@ "Build-time settings for the selected algorithm. The defaults follow the current service recommendations.": "Build-time settings for the selected algorithm. The defaults follow the current service recommendations.", "Building index": "Building index", "Bulk write error during import into \"{0}.{1}\": {2} document(s) inserted.": "Bulk write error during import into \"{0}.{1}\": {2} document(s) inserted.", + "Cache read": "Cache read", + "Cache write": "Cache write", "Cancel": "Cancel", "Cancel Copy": "Cancel Copy", "Cancel this operation": "Cancel this operation", @@ -464,8 +494,10 @@ "Certificate error": "Certificate error", "Change display batch size (currently {0}) in settings": "Change display batch size (currently {0}) in settings", "Change page size": "Change page size", + "Change the cluster type": "Change the cluster type", "Change the image tag": "Change the image tag", "Change the port": "Change the port", + "Change the Query": "Change the Query", "Changelog": "Changelog", "Check Docker again": "Check Docker again", "Check result": "Check result", @@ -485,6 +517,7 @@ "Choose a cluster…": "Choose a cluster…", "Choose a different authentication method...": "Choose a different authentication method...", "Choose a different folder": "Choose a different folder", + "Choose a GitHub Copilot model to continue.": "Choose a GitHub Copilot model to continue.", "Choose a provider to show…": "Choose a provider to show…", "Choose a RU cluster…": "Choose a RU cluster…", "Choose a Subscription…": "Choose a Subscription…", @@ -527,6 +560,7 @@ "Cluster not connectable yet": "Cluster not connectable yet", "Cluster not found (DNS resolution failed)": "Cluster not found (DNS resolution failed)", "Cluster status": "Cluster status", + "Cluster type": "Cluster type", "Cluster-routed via node port": "Cluster-routed via node port", "Clusters": "Clusters", "Collapse fields for {0}": "Collapse fields for {0}", @@ -545,7 +579,10 @@ "Collection name is required.": "Collection name is required.", "Collection names should begin with an underscore or a letter character.": "Collection names should begin with an underscore or a letter character.", "Collection paste cannot automatically copy TTL or unique indexes because they can delete or reject documents.": "Collection paste cannot automatically copy TTL or unique indexes because they can delete or reject documents.", + "Collection read in _id order": "Collection read in _id order", "Collection scan": "Collection scan", + "Collection statistics": "Collection statistics", + "Collection statistics must be a JSON object.": "Collection statistics must be a JSON object.", "Collection View": "Collection View", "Collection View {0} failed because the collection tree node could not be resolved. View ID: {1}; Cluster ID: {2}; Database: {3}; Collection: {4}": "Collection View {0} failed because the collection tree node could not be resolved. View ID: {1}; Cluster ID: {2}; Database: {3}; Collection: {4}", "Collection views": "Collection views", @@ -556,6 +593,8 @@ "Collections in this database": "Collections in this database", "Combined on-disk size of all indexes on this collection.": "Combined on-disk size of all indexes on this collection.", "Combined on-disk size of all user databases. Excludes provisioned disk capacity.": "Combined on-disk size of all user databases. Excludes provisioned disk capacity.", + "Command": "Command", + "Command type": "Command type", "Completed credential checks": "Completed credential checks", "Completed setup steps": "Completed setup steps", "Compressed dimensions": "Compressed dimensions", @@ -621,22 +660,33 @@ "Connections have moved": "Connections have moved", "Connects through a Kubernetes node port. This only works if a cluster node address is reachable from this machine.": "Connects through a Kubernetes node port. This only works if a cluster node address is reachable from this machine.", "Connects to the LoadBalancer external address. The connection string is portable if that address is reachable from the client machine.": "Connects to the LoadBalancer external address. The connection string is portable if that address is reachable from the client machine.", + "Contact Support": "Contact Support", "Container host": "Container host", "Container ID": "Container ID", "Container image": "Container image", "Container OS": "Container OS", "Containers run on": "Containers run on", + "Context": "Context", "context unavailable": "context unavailable", "Context unavailable": "Context unavailable", + "Context window": "Context window", "Continue": "Continue", "Continue anyway": "Continue anyway", "Continue setup": "Continue setup", "Continue to survey": "Continue to survey", "Continuing runs every setup step from the beginning, starting with the Docker check. Nothing has been created on your machine yet.": "Continuing runs every setup step from the beginning, starting with the Docker check. Nothing has been created on your machine yet.", + "Copied": "Copied", + "Copied the {0} command.": "Copied the {0} command.", + "Copied the report as Markdown": "Copied the report as Markdown", + "Copied the report as rich text": "Copied the report as rich text", "Copied to clipboard": "Copied to clipboard", + "Copilot model": "Copilot model", "Copy": "Copy", "Copy \"{sourceCollection}\" from \"{sourceDatabase}\" to \"{targetDatabase}/{targetCollection}\"": "Copy \"{sourceCollection}\" from \"{sourceDatabase}\" to \"{targetDatabase}/{targetCollection}\"", "Copy {0}": "Copy {0}", + "Copy as Markdown": "Copy as Markdown", + "Copy as rich text": "Copy as rich text", + "Copy command": "Copy command", "Copy Connection String": "Copy Connection String", "Copy connection string with password": "Copy connection string with password", "Copy connection string without password": "Copy connection string without password", @@ -651,6 +701,7 @@ "Copy Query": "Copy Query", "Copy Reference: {0}": "Copy Reference: {0}", "Copy Reference: {0}.{1}": "Copy Reference: {0}.{1}", + "Copy the {0} command": "Copy the {0} command", "Copy the Client ID and Client Secret from a Service Account in MongoDB Atlas. We use them to sign in and show the clusters you can access.": "Copy the Client ID and Client Secret from a Service Account in MongoDB Atlas. We use them to sign in and show the clusters you can access.", "Copy the Public Key and Private Key from an API Key in MongoDB Atlas. We use them to sign in and show the clusters you can access.": "Copy the Public Key and Private Key from an API Key in MongoDB Atlas. We use them to sign in and show the clusters you can access.", "Copy the source collection's secondary index definitions. TTL and unique indexes must be pasted separately.": "Copy the source collection's secondary index definitions. TTL and unique indexes must be pasted separately.", @@ -665,11 +716,15 @@ "Copying: {0}": "Copying: {0}", "Copying…": "Copying…", "Cosine (COS)": "Cosine (COS)", + "Cost": "Cost", "Could not add \"{0}\" as a kubeconfig source.": "Could not add \"{0}\" as a kubeconfig source.", "Could not add {0} of the dropped files as kubeconfig sources.": "Could not add {0} of the dropped files as kubeconfig sources.", "Could not be resolved": "Could not be resolved", "Could not check existing collections for default name generation: {0}": "Could not check existing collections for default name generation: {0}", + "Could not check the inputs": "Could not check the inputs", "Could not connect to \"{cluster}\".": "Could not connect to \"{cluster}\".", + "Could not copy the {0} command.": "Could not copy the {0} command.", + "Could not copy to the clipboard": "Could not copy to the clipboard", "Could not detect a collection name in this code block.": "Could not detect a collection name in this code block.", "Could not determine a node address for NodePort service. Check that cluster nodes are accessible.": "Could not determine a node address for NodePort service. Check that cluster nodes are accessible.", "Could not find {0}": "Could not find {0}", @@ -682,6 +737,8 @@ "Could not open {0} of the dropped files for preview.": "Could not open {0} of the dropped files for preview.", "Could not read collections": "Could not read collections", "Could not read databases": "Could not read databases", + "Count scan work": "Count scan work", + "Count uses nCounted, not nReturned. Selectivity, returned-document efficiency, coverage and sort rules are not applicable. The rating uses index access and documents examined versus collection size.": "Count uses nCounted, not nReturned. Selectivity, returned-document efficiency, coverage and sort rules are not applicable. The rating uses index access and documents examined versus collection size.", "Counting documents in the source collection...": "Counting documents in the source collection...", "Counting source indexes…": "Counting source indexes…", "Covered query": "Covered query", @@ -728,6 +785,8 @@ "Credentials may have expired. Re-authenticate with your cluster.": "Credentials may have expired. Re-authenticate with your cluster.", "Credentials missing": "Credentials missing", "Credentials updated successfully.": "Credentials updated successfully.", + "Credits": "Credits", + "Credits per 1M tokens": "Credits per 1M tokens", "Daemon architecture": "Daemon architecture", "daemon not running": "daemon not running", "daemon starting": "daemon starting", @@ -736,8 +795,10 @@ "Dashboard: {clusterDisplayName} / {databaseName}": "Dashboard: {clusterDisplayName} / {databaseName}", "Data": "Data", "Data Migration…": "Data Migration…", + "Data Model Change": "Data Model Change", "Database": "Database", "database \"{0}\"": "database \"{0}\"", + "Database and collection": "Database and collection", "Database name cannot be longer than 64 characters.": "Database name cannot be longer than 64 characters.", "Database name cannot contain any of the following characters: \"{0}{1}\"": "Database name cannot contain any of the following characters: \"{0}{1}\"", "Database name is required when collection is specified": "Database name is required when collection is specified", @@ -748,6 +809,8 @@ "Databases / Collections": "Databases / Collections", "Databases in this cluster": "Databases in this cluster", "Default kubeconfig": "Default kubeconfig", + "Default: first available model": "Default: first available model", + "Default: first Powerful model": "Default: first Powerful model", "Delete": "Delete", "Delete \"{connectionName}\"?": "Delete \"{connectionName}\"?", "Delete \"{nodeName}\"?": "Delete \"{nodeName}\"?", @@ -779,11 +842,14 @@ "Descending (-1)": "Descending (-1)", "Destination: \"{0}\"": "Destination: \"{0}\"", "detailed execution analysis": "detailed execution analysis", + "detected": "detected", + "Detected from the explain output": "Detected from the explain output", "Detected problem": "Detected problem", "Dev container": "Dev container", "Develop and test locally": "Develop and test locally", "Development": "Development", "Diagnostic reference {0}. Quote it when reporting this.": "Diagnostic reference {0}. Quote it when reporting this.", + "Diagnostics": "Diagnostics", "Did you mean '{0}'?": "Did you mean '{0}'?", "Dimensions": "Dimensions", "Dimensions and similarity": "Dimensions and similarity", @@ -878,7 +944,10 @@ "DocumentDB: {0}@{1}/{2}": "DocumentDB: {0}@{1}/{2}", "DocumentDB: {0}/{1}": "DocumentDB: {0}/{1}", "Documents": "Documents", + "Documents counted": "Documents counted", "Documents Examined": "Documents Examined", + "Documents passed to pipeline": "Documents passed to pipeline", + "Documents produced by the initial cursor stage, not the final pipeline output. Selectivity and efficiency describe only this initial stage.": "Documents produced by the initial cursor stage, not the final pipeline output. Selectivity and efficiency describe only this initial stage.", "Documents Returned": "Documents Returned", "Documents with matching _id values will be handled based on your conflict resolution setting.": "Documents with matching _id values will be handled based on your conflict resolution setting.", "Does this occur consistently? ": "Does this occur consistently? ", @@ -896,6 +965,7 @@ "Duplicate key error for document with _id: {0}. {1}": "Duplicate key error for document with _id: {0}. {1}", "Duplicate key error: {0}": "Duplicate key error: {0}", "Duplicate key error. {0}": "Duplicate key error. {0}", + "Duration": "Duration", "e.g. 1536": "e.g. 1536", "e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012": "e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012", "e.g., abcdef12": "e.g., abcdef12", @@ -903,7 +973,11 @@ "e.g., mdb_sa_id_6501…": "e.g., mdb_sa_id_6501…", "Each line will be run independently.": "Each line will be run independently.", "Edit and retry": "Edit and retry", + "Edit collection statistics": "Edit collection statistics", + "Edit database and collection": "Edit database and collection", + "Edit indexes": "Edit indexes", "Edit Kubeconfig": "Edit Kubeconfig", + "Edit query": "Edit query", "Edit selected document": "Edit selected document", "Efficient sorting": "Efficient sorting", "Element with id of {rootId} not found.": "Element with id of {rootId} not found.", @@ -979,11 +1053,13 @@ "Error: {0}": "Error: {0}", "Error: {error}": "Error: {error}", "Errors found in file \"{path}\". Please fix these:": "Errors found in file \"{path}\". Please fix these:", + "estimated": "estimated", "Euclidean (L2)": "Euclidean (L2)", "Every MongoDB Atlas credential is signed in.": "Every MongoDB Atlas credential is signed in.", "Everything checked out with MongoDB Atlas.": "Everything checked out with MongoDB Atlas.", "Everything the readiness check established, in the order it was established.": "Everything the readiness check established, in the order it was established.", "Everything was successful. Your credential was checked and saved.": "Everything was successful. Your credential was checked and saved.", + "Exactly as sent, after literal values in filters were masked. The instruction prompt is not included.": "Exactly as sent, after literal values in filters were masked. The instruction prompt is not included.", "Excellent": "Excellent", "Exclude selected fields": "Exclude selected fields", "Execute as One": "Execute as One", @@ -1001,10 +1077,15 @@ "Expected a document object like { field: 1 }, got: {0}": "Expected a document object like { field: 1 }, got: {0}", "Expire after (seconds)": "Expire after (seconds)", "Expires after": "Expires after", + "Explain output": "Explain output", + "Explain output error. {0}": "Explain output error. {0}", + "Explain output is valid. Detected a {0} command.": "Explain output is valid. Detected a {0} command.", + "Explain output parsed.": "Explain output parsed.", "Explain(aggregate) completed [{durationMs}ms]": "Explain(aggregate) completed [{durationMs}ms]", "Explain(count) completed [{durationMs}ms]": "Explain(count) completed [{durationMs}ms]", "Explain(find) completed [{durationMs}ms]": "Explain(find) completed [{durationMs}ms]", "Explaining…": "Explaining…", + "Explore with your own input…": "Explore with your own input…", "Export": "Export", "Export complete. Exported document count: {documentCount}": "Export complete. Exported document count: {documentCount}", "Export Current Query Results…": "Export Current Query Results…", @@ -1020,6 +1101,7 @@ "Extension dependency with id \"{0}\" must be updated.": "Extension dependency with id \"{0}\" must be updated.", "Extension Documentation": "Extension Documentation", "ExternalName services are not directly supported. Use the external DNS name to connect manually.": "ExternalName services are not directly supported. Use the external DNS name to connect manually.", + "Extracted query": "Extracted query", "failed": "failed", "Failed to {0} documents.": "Failed to {0} documents.", "Failed to {action} index \"{indexName}\": {error} [{durationMs}ms]": "Failed to {action} index \"{indexName}\": {error} [{durationMs}ms]", @@ -1087,6 +1169,7 @@ "Failed to load kubeconfig from \"{0}\": {1}. Ensure the file exists and contains valid YAML.": "Failed to load kubeconfig from \"{0}\": {1}. Ensure the file exists and contains valid YAML.", "Failed to load kubeconfig from pasted YAML: {0}. Ensure the clipboard contains a valid kubeconfig document.": "Failed to load kubeconfig from pasted YAML: {0}. Ensure the clipboard contains a valid kubeconfig document.", "Failed to load kubeconfig: {0}. Ensure a kubeconfig file exists at the Kubernetes default kubeconfig path or the KUBECONFIG environment variable is set.": "Failed to load kubeconfig: {0}. Ensure a kubeconfig file exists at the Kubernetes default kubeconfig path or the KUBECONFIG environment variable is set.", + "Failed to load model settings: {0}": "Failed to load model settings: {0}", "Failed to load MongoDB Atlas clusters.": "Failed to load MongoDB Atlas clusters.", "Failed to load MongoDB Atlas discovery: {0}": "Failed to load MongoDB Atlas discovery: {0}", "Failed to load selected items from storage.": "Failed to load selected items from storage.", @@ -1126,6 +1209,7 @@ "Failed to save credentials: {0}": "Failed to save credentials: {0}", "Failed to save credentials: connection not found in storage.": "Failed to save credentials: connection not found in storage.", "Failed to save credentials.": "Failed to save credentials.", + "Failed to save model choice: {0}": "Failed to save model choice: {0}", "Failed to save the dashboard preference.": "Failed to save the dashboard preference.", "Failed to save updated credentials: {error}": "Failed to save updated credentials: {error}", "Failed to scan schema for \"{0}\".": "Failed to scan schema for \"{0}\".", @@ -1147,6 +1231,7 @@ "Fair": "Fair", "Fast execution": "Fast execution", "Fast, light build for smaller collections.": "Fast, light build for smaller collections.", + "Feature preview": "Feature preview", "Federated": "Federated", "Feedback is turned off by the \"{0}\" setting or your organization's policy.": "Feedback is turned off by the \"{0}\" setting or your organization's policy.", "Fetch Overhead": "Fetch Overhead", @@ -1163,16 +1248,23 @@ "Filter indexes": "Filter indexes", "Filter indexes…": "Filter indexes…", "Filter: Enter the DocumentDB query filter": "Filter: Enter the DocumentDB query filter", + "Find out why a query is slow": "Find out why a query is slow", "Find Query": "Find Query", "Find Query: execute the current editor values": "Find Query: execute the current editor values", "Finished importing": "Finished importing", + "Fix the collection statistics to continue.": "Fix the collection statistics to continue.", + "Fix the explain output to continue.": "Fix the explain output to continue.", "Fix the highlighted values in Advanced settings to continue.": "Fix the highlighted values in Advanced settings to continue.", + "Fix the indexes to continue.": "Fix the indexes to continue.", + "Fix the query to continue.": "Fix the query to continue.", "Folder": "Folder", "Folder name cannot be empty": "Folder name cannot be empty", "For example, {0}": "For example, {0}", + "For issues, docs and AI agents": "For issues, docs and AI agents", "Found, version {0}": "Found, version {0}", "Found, version not reported": "Found, version not reported", "From the connection string": "From the connection string", + "from the explain output": "from the explain output", "Full collection scan": "Full collection scan", "Generate": "Generate", "Generate credentials automatically": "Generate credentials automatically", @@ -1187,6 +1279,7 @@ "Get Docker Desktop for Mac": "Get Docker Desktop for Mac", "Get Docker Desktop for Windows": "Get Docker Desktop for Windows", "Get personalized recommendations to optimize your query performance. AI will analyze your cluster configuration, index usage, execution plan, and more to suggest specific improvements.": "Get personalized recommendations to optimize your query performance. AI will analyze your cluster configuration, index usage, execution plan, and more to suggest specific improvements.", + "Get the explain output": "Get the explain output", "GitHub Codespaces": "GitHub Codespaces", "GitHub Copilot is not available. Please install the GitHub Copilot extension and ensure you have an active subscription.": "GitHub Copilot is not available. Please install the GitHub Copilot extension and ensure you have an active subscription.", "Give Feedback": "Give Feedback", @@ -1208,20 +1301,27 @@ "Hidden": "Hidden", "hide": "hide", "Hide": "Hide", + "Hide collection statistics": "Hide collection statistics", + "Hide database and collection": "Hide database and collection", "Hide details": "Hide details", "Hide index": "Hide index", "Hide index {0}": "Hide index {0}", "Hide index?": "Hide index?", "Hide Index…": "Hide Index…", + "Hide indexes": "Hide indexes", + "Hide query editor": "Hide query editor", "Hide secret": "Hide secret", + "Hide the cluster type": "Hide the cluster type", "Hide the image tag setting": "Hide the image tag setting", "Hide the port setting": "Hide the port setting", "Hiding prevents the query planner from using this index.": "Hiding prevents the query planner from using this index.", "Hiding…": "Hiding…", "High availability": "High availability", "High Availability": "High Availability", + "High cost": "High cost", "High efficiency ratio": "High efficiency ratio", "High multikey expansion": "High multikey expansion", + "High priority": "High priority", "HIGH PRIORITY": "HIGH PRIORITY", "HNSW": "HNSW", "Host": "Host", @@ -1242,6 +1342,7 @@ "Ignore": "Ignore", "Ignoring the following files that do not match the \"*.json\" file name pattern:": "Ignoring the following files that do not match the \"*.json\" file name pattern:", "Image": "Image", + "Image input": "Image input", "Image tag": "Image tag", "Image tag may contain only letters, numbers, dots, dashes, and underscores.": "Image tag may contain only letters, numbers, dots, dashes, and underscores.", "Import": "Import", @@ -1286,6 +1387,7 @@ "Index \"{indexName}\" is already hidden.": "Index \"{indexName}\" is already hidden.", "Index \"{indexName}\" is not hidden.": "Index \"{indexName}\" is not hidden.", "Index actions": "Index actions", + "Index actions are disabled in the explorer.": "Index actions are disabled in the explorer.", "Index copy services were not configured.": "Index copy services were not configured.", "Index created.": "Index created.", "Index creation cancelled": "Index creation cancelled", @@ -1316,15 +1418,21 @@ "Indexed fields": "Indexed fields", "Indexes": "Indexes", "Indexes / Size": "Indexes / Size", + "Indexes must be a JSON array of index objects.": "Indexes must be a JSON array of index objects.", "Indexes to copy: {0}": "Indexes to copy: {0}", "Indexes updated.": "Indexes updated.", "Inferred from the Docker application installed on this machine.": "Inferred from the Docker application installed on this machine.", "Inferred from the Docker context that is currently active.": "Inferred from the Docker context that is currently active.", "Info from the webview: ": "Info from the webview: ", + "Initial cursor execution time is unavailable. Any displayed pipeline estimate includes later stages; an initial-stage performance rating is not assessed.": "Initial cursor execution time is unavailable. Any displayed pipeline estimate includes later stages; an initial-stage performance rating is not assessed.", + "Initial pipeline stage only": "Initial pipeline stage only", + "Initial-stage selectivity": "Initial-stage selectivity", "Initializing shell runtime": "Initializing shell runtime", "Initializing task...": "Initializing task...", "Initializing…": "Initializing…", "Inner product (IP)": "Inner product (IP)", + "Input": "Input", + "Inputs sent to the model": "Inputs sent to the model", "Inserted {0} document(s).": "Inserted {0} document(s).", "Install Azure Account Extension...": "Install Azure Account Extension...", "Install Docker Desktop, then restart VS Code. A VS Code that was already running does not pick up the PATH the installer adds, so Docker stays undetected until it is restarted. Reloading the window is not enough. Start Docker Desktop and wait until it is ready, then check again.": "Install Docker Desktop, then restart VS Code. A VS Code that was already running does not pick up the PATH the installer adds, so Docker stays undetected until it is restarted. Reloading the window is not enough. Start Docker Desktop and wait until it is ready, then check again.", @@ -1418,19 +1526,26 @@ "Learn more about local connections.": "Learn more about local connections.", "Learn more about the utility model used.": "Learn more about the utility model used.", "Learn more…": "Learn more…", + "Leave empty to use the extracted query. Find accepts a filter or { filter, sort, projection, skip, limit }; aggregate needs a pipeline array. Extraction from a plan can be partial.": "Leave empty to use the extracted query. Find accepts a filter or { filter, sort, projection, skip, limit }; aggregate needs a pipeline array. Extraction from a plan can be partial.", "Legacy, simplest": "Legacy, simplest", "Length must be greater than 1": "Length must be greater than 1", + "Lets GitHub Copilot reuse an existing index instead of recommending a duplicate.": "Lets GitHub Copilot reuse an existing index instead of recommending a duplicate.", "Level up": "Level up", + "Lightweight": "Lightweight", "Limit": "Limit", + "Line {0}, column {1}: {2}": "Line {0}, column {1}: {2}", "Lines will be joined into a single expression and executed.": "Lines will be joined into a single expression and executed.", "Linux": "Linux", "Linux containers required": "Linux containers required", "Lists": "Lists", + "Load a sample plan:": "Load a sample plan:", "Load More...": "Load More...", + "Load the {0} sample plan": "Load the {0} sample plan", "LoadBalancer external IP is not assigned. Using node InternalIP as a fallback — this address may not be reachable outside the cluster. Verify that the node is accessible from your machine.": "LoadBalancer external IP is not assigned. Using node InternalIP as a fallback — this address may not be reachable outside the cluster. Verify that the node is accessible from your machine.", "LoadBalancer external IP is not yet assigned and no NodePort fallback is available. The service may still be provisioning.": "LoadBalancer external IP is not yet assigned and no NodePort fallback is available. The service may still be provisioning.", "LoadBalancer pending": "LoadBalancer pending", "Loaded {0} document(s) from \"{1}\"": "Loaded {0} document(s) from \"{1}\"", + "Loaded the {0} sample plan.": "Loaded the {0} sample plan.", "Loading \"{0}\"...": "Loading \"{0}\"...", "Loading Atlas clusters…": "Loading Atlas clusters…", "Loading Atlas projects…": "Loading Atlas projects…", @@ -1445,6 +1560,7 @@ "Loading indexes": "Loading indexes", "Loading indexes…": "Loading indexes…", "Loading inventory": "Loading inventory", + "Loading models…": "Loading models…", "Loading MongoDB Atlas credentials…": "Loading MongoDB Atlas credentials…", "Loading performance rating": "Loading performance rating", "Loading resources...": "Loading resources...", @@ -1464,12 +1580,16 @@ "Local port-forward required": "Local port-forward required", "localhost:{0}": "localhost:{0}", "Location": "Location", + "Long context": "Long context", "Lost connection to the cluster.": "Lost connection to the cluster.", + "Low cost": "Low cost", "Low efficiency ratio": "Low efficiency ratio", "Low filter selectivity": "Low filter selectivity", + "Low priority": "Low priority", "LOW PRIORITY": "LOW PRIORITY", "Low-cardinality index": "Low-cardinality index", "macOS": "macOS", + "Maintenance": "Maintenance", "Make sure the correct kubeconfig YAML is in your clipboard before continuing.": "Make sure the correct kubeconfig YAML is in your clipboard before continuing.", "Manage Accounts": "Manage Accounts", "Manage Azure Accounts": "Manage Azure Accounts", @@ -1484,6 +1604,8 @@ "Manually enter a custom tenant ID": "Manually enter a custom tenant ID", "Maximum degree": "Maximum degree", "mdb_sa_sk_…": "mdb_sa_sk_…", + "Medium cost": "Medium cost", + "Medium priority": "Medium priority", "MEDIUM PRIORITY": "MEDIUM PRIORITY", "Microsoft Entra account": "Microsoft Entra account", "Microsoft Entra ID": "Microsoft Entra ID", @@ -1491,6 +1613,9 @@ "Microsoft Entra ID (Managed Identity)": "Microsoft Entra ID (Managed Identity)", "Migration Providers: {0}": "Migration Providers: {0}", "Mode: {0}": "Mode: {0}", + "Model": "Model", + "Model details": "Model details", + "Model ID": "Model ID", "Moderate efficiency ratio": "Moderate efficiency ratio", "Modify index?": "Modify index?", "Modify Index…": "Modify Index…", @@ -1515,6 +1640,7 @@ "More access is required": "More access is required", "More actions": "More actions", "More cluster actions": "More cluster actions", + "More context gives sharper ratings and recommendations. Everything here can stay empty.": "More context gives sharper ratings and recommendations. Everything here can stay empty.", "More options": "More options", "Move": "Move", "Move \"{0}\"?": "Move \"{0}\"?", @@ -1529,6 +1655,7 @@ "name=\"{0}\", family={1}, id={2}, version={3}": "name=\"{0}\", family={1}, id={2}, version={3}", "Named pipe": "Named pipe", "Namespace": "Namespace", + "Namespace not provided": "Namespace not provided", "Namespaces that were scanned but where no DocumentDB target was found. These are grouped here to keep the list of connectable namespaces uncluttered. Expand to see which namespaces were checked.": "Namespaces that were scanned but where no DocumentDB target was found. These are grouped here to keep the list of connectable namespaces uncluttered. Expand to see which namespaces were checked.", "Neutral": "Neutral", "New Collection": "New Collection", @@ -1566,6 +1693,7 @@ "No collections in \"{database}\"": "No collections in \"{database}\"", "No connections selected to remove.": "No connections selected to remove.", "No Connectivity": "No Connectivity", + "No Copilot models available": "No Copilot models available", "No copyable indexes are selected.": "No copyable indexes are selected.", "No credentials found for cluster \"{0}\"": "No credentials found for cluster \"{0}\"", "No credentials found for cluster {0}": "No credentials found for cluster {0}", @@ -1580,8 +1708,10 @@ "No entries": "No entries", "No folder selected.": "No folder selected.", "No high availability": "No high availability", + "No image input": "No image input", "No index changes needed at this time.": "No index changes needed at this time.", "No index changes recommended": "No index changes recommended", + "No index changes recommended.": "No index changes recommended.", "No index changes were recommended for this query. See the other cards for the full analysis and query execution details.": "No index changes were recommended for this query. See the other cards for the full analysis and query execution details.", "No index selected.": "No index selected.", "No index used": "No index used", @@ -1628,20 +1758,28 @@ "No tenants selected. Tenant filtering disabled (all tenants will be shown).": "No tenants selected. Tenant filtering disabled (all tenants will be shown).", "No tenants were returned for the available Azure accounts.": "No tenants were returned for the available Azure accounts.", "no token was returned": "no token was returned", + "No tool calling": "No tool calling", "No, only copy documents": "No, only copy documents", "node-routed": "node-routed", "Node.js: {0}": "Node.js: {0}", "Non-default indexes with zero recorded usage since the server started tracking. Consider reviewing them. Unused indexes consume storage and slow writes.": "Non-default indexes with zero recorded usage since the server started tracking. Consider reviewing them. Unused indexes consume storage and slow writes.", "None": "None", "None (collection scan)": "None (collection scan)", + "none reported": "none reported", "Not accessible": "Not accessible", "Not accessible from WSL": "Not accessible from WSL", + "Not applicable": "Not applicable", + "Not applicable to count queries": "Not applicable to count queries", "Not available": "Not available", + "Not available for this analysis.": "Not available for this analysis.", "not available in this WSL distribution": "not available in this WSL distribution", "Not copied: {0}. Search-index exclusions are best-effort and may be incomplete.": "Not copied: {0}. Search-index exclusions are best-effort and may be incomplete.", "Not directly reachable": "Not directly reachable", "Not found": "Not found", "Not included": "Not included", + "Not provided": "Not provided", + "Not rated for pipeline $sort": "Not rated for pipeline $sort", + "Not reported for this model": "Not reported for this model", "Not reported yet": "Not reported yet", "not running": "not running", "Not running": "Not running", @@ -1653,6 +1791,7 @@ "Note: You can disable these URL handling confirmations in the extension settings.": "Note: You can disable these URL handling confirmations in the extension settings.", "Nothing identified it: the check reached neither a daemon nor a usable context.": "Nothing identified it: the check reached neither a daemon nor a usable context.", "Nothing is downloaded or created on your machine until you choose to start in the Configure step.": "Nothing is downloaded or created on your machine until you choose to start in the Configure step.", + "Nothing is sent to GitHub Copilot until you select Analyze query.": "Nothing is sent to GitHub Copilot until you select Analyze query.", "Number of collections in database \"{database}\".": "Number of collections in database \"{database}\".", "Number of documents returned by the query": "Number of documents returned by the query", "Number of documents scanned to find matching results. Should be close to documents returned for optimal performance.": "Number of documents scanned to find matching results. Should be close to documents returned for optimal performance.", @@ -1671,6 +1810,8 @@ "Only index documents that match this filter. Enter a JSON object.": "Only index documents that match this filter. Enter a JSON object.", "Only indexes documents that contain the field.": "Only indexes documents that contain the field.", "Only pasted kubeconfig sources can be viewed. Use \"Edit Kubeconfig\" for file sources.": "Only pasted kubeconfig sources can be viewed. Use \"Edit Kubeconfig\" for file sources.", + "Only the explain output is required. It is checked as you type.": "Only the explain output is required. It is checked as you type.", + "Only the initial cursor stage ($match/$sort) is analyzed; later pipeline stages ({0}) are not rated.": "Only the initial cursor stage ($match/$sort) is analyzed; later pipeline stages ({0}) are not rated.", "Only the selected paths, and every field nested under them, are indexed. All other fields are excluded.": "Only the selected paths, and every field nested under them, are indexed. All other fields are excluded.", "Only this credential and its secrets are removed. Other credentials stay signed in.": "Only this credential and its secrets are removed. Other credentials stay signed in.", "Open \"{0}.{1}\" in Query Playground": "Open \"{0}.{1}\" in Query Playground", @@ -1709,13 +1850,21 @@ "Operation timed out after {0} seconds.": "Operation timed out after {0} seconds.", "Optimization Opportunities": "Optimization Opportunities", "Optimizing the index on {0} can improve query performance by better matching the query pattern.": "Optimizing the index on {0} can improve query performance by better matching the query pattern.", + "Optional": "Optional", + "Optional context": "Optional context", "Optional settings and a preview of the generated index specification.": "Optional settings and a preview of the generated index specification.", "Options": "Options", "Organization": "Organization", "Organization ID": "Organization ID", "OS: {0}": "OS: {0}", + "Other": "Other", "Other namespaces": "Other namespaces", "Other options": "Other options", + "Output": "Output", + "Output of db..getIndexes() or an $indexStats array, or one array combining both.": "Output of db..getIndexes() or an $indexStats array, or one array combining both.", + "Output of db..stats() or the collStats command. The document count is the part that matters most.": "Output of db..stats() or the collStats command. The document count is the part that matters most.", + "Outside {0} editor": "Outside {0} editor", + "Overall execution time (estimate)": "Overall execution time (estimate)", "Overwrite existing documents": "Overwrite existing documents", "Overwrite existing documents that share the same _id; other write errors will abort the operation.": "Overwrite existing documents that share the same _id; other write errors will abort the operation.", "Parent path": "Parent path", @@ -1726,6 +1875,7 @@ "Partial filter expression": "Partial filter expression", "Partial filter expression, custom collation": "Partial filter expression, custom collation", "Partial filter expression: enter a JSON object": "Partial filter expression: enter a JSON object", + "passed to pipeline": "passed to pipeline", "Password": "Password", "Password cannot be empty": "Password cannot be empty", "Password contains a character DocumentDB doesn't support, such as an emoji or a newer symbol.": "Password contains a character DocumentDB doesn't support, such as an emoji or a newer symbol.", @@ -1738,11 +1888,15 @@ "Password must not start or end with a space.": "Password must not start or end with a space.", "Password must not start with a hyphen (-).": "Password must not start with a hyphen (-).", "Paste a find query from clipboard into the editors": "Paste a find query from clipboard into the editors", + "Paste an explain plan containing queryPlanner, executionStats, or stages.": "Paste an explain plan containing queryPlanner, executionStats, or stages.", "Paste Collection": "Paste Collection", "Paste Indexes": "Paste Indexes", "Paste indexes into \"{0}\"?": "Paste indexes into \"{0}\"?", "Paste kubeconfig YAML…": "Paste kubeconfig YAML…", "Paste Query": "Paste Query", + "Paste the complete result of explain(\"executionStats\"). JSON, Extended JSON and shell output are accepted.": "Paste the complete result of explain(\"executionStats\"). JSON, Extended JSON and shell output are accepted.", + "Paste the explain output of a find, aggregate or count command. Query Insights rates how efficiently it ran, shows the execution plan, and asks GitHub Copilot for index and query recommendations.": "Paste the explain output of a find, aggregate or count command. Query Insights rates how efficiently it ran, shows the execution plan, and asks GitHub Copilot for index and query recommendations.", + "Paste the explain output to continue.": "Paste the explain output to continue.", "Pasted kubeconfig \"{0}\" is empty or unreadable. Remove and re-add the source.": "Pasted kubeconfig \"{0}\" is empty or unreadable. Remove and re-add the source.", "Pasted kubeconfig YAML is empty.": "Pasted kubeconfig YAML is empty.", "Pasted YAML {0}": "Pasted YAML {0}", @@ -1756,6 +1910,7 @@ "Pick how we sign in to MongoDB Atlas.": "Pick how we sign in to MongoDB Atlas.", "Ping latency {0}": "Ping latency {0}", "Ping latency not measured yet": "Ping latency not measured yet", + "Plan": "Plan", "Platform": "Platform", "Playground": "Playground", "Please authenticate first by expanding the tree item of the selected cluster.": "Please authenticate first by expanding the tree item of the selected cluster.", @@ -1794,9 +1949,12 @@ "Port-forward tunnel stopped: 127.0.0.1:{0} -> {1}/{2}:{3}": "Port-forward tunnel stopped: 127.0.0.1:{0} -> {1}/{2}:{3}", "Powered by {0} via GitHub Copilot": "Powered by {0} via GitHub Copilot", "Powered by {0} via GitHub Copilot · {1}s": "Powered by {0} via GitHub Copilot · {1}s", + "Powerful": "Powerful", + "Prefilled from your session": "Prefilled from your session", "Preparing to copy {0} indexes...": "Preparing to copy {0} indexes...", "Press Escape to exit editor": "Press Escape to exit editor", "Preview": "Preview", + "Preview · tuned for find queries": "Preview · tuned for find queries", "Preview as JSON": "Preview as JSON", "Preview Clipboard": "Preview Clipboard", "Privacy Statement": "Privacy Statement", @@ -1812,9 +1970,15 @@ "Project: Specify which fields to include or exclude": "Project: Specify which fields to include or exclude", "Projection": "Projection", "Projection mode": "Projection mode", + "Prompt": "Prompt", "Properties": "Properties", "Provide your MongoDB Atlas API Key": "Provide your MongoDB Atlas API Key", "Provide your MongoDB Atlas Service Account": "Provide your MongoDB Atlas Service Account", + "Provide your query data": "Provide your query data", + "Provided": "Provided", + "Provided · {0} documents": "Provided · {0} documents", + "Provided · {0} indexes": "Provided · {0} indexes", + "Provided · 1 index": "Provided · 1 index", "Provider": "Provider", "Provider \"{0}\" does not have resource type \"{1}\".": "Provider \"{0}\" does not have resource type \"{1}\".", "Provisioned storage": "Provisioned storage", @@ -1824,6 +1988,7 @@ "Public Key": "Public Key", "Pulling official image": "Pulling official image", "Qualified Name": "Qualified Name", + "Query": "Query", "Query completed in {0}ms.\n\nThis is acceptable for most use cases, though optimization could improve responsiveness.": "Query completed in {0}ms.\n\nThis is acceptable for most use cases, though optimization could improve responsiveness.", "Query completed in {0}ms.\n\nThis is excellent performance and provides a responsive user experience.": "Query completed in {0}ms.\n\nThis is excellent performance and provides a responsive user experience.", "Query copied to clipboard": "Query copied to clipboard", @@ -1837,13 +2002,24 @@ "Query generation failed with the error: {0}": "Query generation failed with the error: {0}", "query insights": "query insights", "Query Insights": "Query Insights", + "Query Insights (POC)": "Query Insights (POC)", "Query Insights actions": "Query Insights actions", + "Query Insights analysis results": "Query Insights analysis results", "Query Insights APIs not initialized. Client may not be properly connected.": "Query Insights APIs not initialized. Client may not be properly connected.", + "Query Insights Configuration": "Query Insights Configuration", + "Query Insights Explorer": "Query Insights Explorer", + "Query Insights Explorer (POC)": "Query Insights Explorer (POC)", + "Query Insights Explorer steps": "Query Insights Explorer steps", "Query Insights is not available for Azure Cosmos DB for MongoDB (RU) accounts.": "Query Insights is not available for Azure Cosmos DB for MongoDB (RU) accounts.", "Query Insights is not supported on Azure Cosmos DB for MongoDB (RU) clusters.": "Query Insights is not supported on Azure Cosmos DB for MongoDB (RU) clusters.", "Query Insights Not Available": "Query Insights Not Available", + "Query Insights prompt for {type} query: built-in ({source})": "Query Insights prompt for {type} query: built-in ({source})", + "Query Insights prompt for {type} query: built-in ({source}); the custom file {path} could not be read": "Query Insights prompt for {type} query: built-in ({source}); the custom file {path} could not be read", + "Query Insights prompt for {type} query: custom file {path}": "Query Insights prompt for {type} query: custom file {path}", "Query Insights Stage 2 completed with execution error": "Query Insights Stage 2 completed with execution error", "Query Insights utilities": "Query Insights utilities", + "Query Insights: Optimization Opportunities": "Query Insights: Optimization Opportunities", + "Query or pipeline": "Query or pipeline", "query or queryObject is required when not using pre-loaded data": "query or queryObject is required when not using pre-loaded data", "Query Performance Analysis": "Query Performance Analysis", "Query Performance Insight": "Query Performance Insight", @@ -1876,8 +2052,12 @@ "Recommendation: Create Index": "Recommendation: Create Index", "Recommendation: Drop Index": "Recommendation: Drop Index", "Recommendation: Modify Index": "Recommendation: Modify Index", + "Recommendations": "Recommendations", + "Recommendations are copy-only; the explorer never changes a database.": "Recommendations are copy-only; the explorer never changes a database.", + "Recommendations are copy-only. The explorer never changes a database.": "Recommendations are copy-only. The explorer never changes a database.", "Recommended": "Recommended", "Recommended Index": "Recommended Index", + "Reconfigure": "Reconfigure", "Reconnect now with the updated credentials": "Reconnect now with the updated credentials", "Reconnecting...": "Reconnecting...", "Recovery command copied.": "Recovery command copied.", @@ -1886,6 +2066,7 @@ "Recreating reuses the existing data volume, so the original credentials and image are kept.": "Recreating reuses the existing data volume, so the original credentials and image are kept.", "Refresh": "Refresh", "Refresh indexes": "Refresh indexes", + "Refresh models": "Refresh models", "Refresh query and query insights": "Refresh query and query insights", "Refresh: {0}": "Refresh: {0}", "Refreshing Azure discovery tree…": "Refreshing Azure discovery tree…", @@ -1923,6 +2104,7 @@ "Report an issue": "Report an issue", "Request sent. Analyzing…": "Request sent. Analyzing…", "Request sent. Awaiting response.": "Request sent. Awaiting response.", + "Required": "Required", "Rerun the last executed query": "Rerun the last executed query", "Reset form": "Reset form", "Reset image tag to {0}": "Reset image tag to {0}", @@ -1956,8 +2138,10 @@ "Review the setup settings, then start DocumentDB Local.": "Review the setup settings, then start DocumentDB Local.", "Review this Service Accountȁs roles and IP access list in your browser.": "Review this Service Accountȁs roles and IP access list in your browser.", "Revisit connection details and try again.": "Revisit connection details and try again.", + "Rich text is not available here. Copied the report as plain text": "Rich text is not available here. Copied the report as plain text", "Right-click a cluster, database, or collection in the DocumentDB panel to open an interactive shell.": "Right-click a cluster, database, or collection in the DocumentDB panel to open an interactive shell.", "Right-click a database or collection in the DocumentDB panel to create a new Query Playground.": "Right-click a database or collection in the DocumentDB panel to create a new Query Playground.", + "Risks": "Risks", "Role assignment \"{0}\" created for the {2} resource \"{1}\".": "Role assignment \"{0}\" created for the {2} resource \"{1}\".", "Role Assignment {0} created for {1}": "Role Assignment {0} created for {1}", "Role Assignment {0} failed for {1}": "Role Assignment {0} failed for {1}", @@ -1965,6 +2149,7 @@ "Run a command to try connecting again, or close this terminal.": "Run a command to try connecting again, or close this terminal.", "Run All": "Run All", "Run as Is": "Run as Is", + "Run one of these in the DocumentDB shell or mongosh with your own collection and filter, then copy the whole result.": "Run one of these in the DocumentDB shell or mongosh with your own collection and filter, then copy the whole result.", "Run the entire file ({0}+Shift+Enter)": "Run the entire file ({0}+Shift+Enter)", "Run this block ({0}+Enter)": "Run this block ({0}+Enter)", "Running": "Running", @@ -1990,6 +2175,7 @@ "Save document to the database": "Save document to the database", "Save the connection": "Save the connection", "Save to the database": "Save to the database", + "Saved choice": "Saved choice", "Saved connections that depend on this source will need to be reconfigured. Active port-forward tunnels for this source will be stopped.": "Saved connections that depend on this source will need to be reconfigured. Active port-forward tunnels for this source will be stopped.", "Saved credentials for DocumentDB Local are missing, so this instance cannot be opened. Delete it and set it up again to start fresh (this erases its data).": "Saved credentials for DocumentDB Local are missing, so this instance cannot be opened. Delete it and set it up again to start fresh (this erases its data).", "Saving \"{path}\" will update the entity \"{name}\" to the cloud.": "Saving \"{path}\" will update the entity \"{name}\" to the cloud.", @@ -2034,9 +2220,11 @@ "Select the identity to use for this connection": "Select the identity to use for this connection", "Select the local connection type…": "Select the local connection type…", "Select view type": "Select view type", + "selected": "selected", "Selected items cannot be moved.": "Selected items cannot be moved.", "Selected subscriptions: {0}": "Selected subscriptions: {0}", "Selected tenants: {0}": "Selected tenants: {0}", + "Selection": "Selection", "Selectivity": "Selectivity", "Server": "Server", "Server version": "Server version", @@ -2070,9 +2258,11 @@ "Severe multikey expansion": "Severe multikey expansion", "Shard Key": "Shard Key", "SHARD_MERGE · {0} shards": "SHARD_MERGE · {0} shards", + "SHARD_MERGE · {0} shards · {1} · {2}": "SHARD_MERGE · {0} shards · {1} · {2}", "SHARD_MERGE · {0} shards · {1} docs · {2}ms": "SHARD_MERGE · {0} shards · {1} docs · {2}ms", "Shard: {0}": "Shard: {0}", "Shards": "Shards", + "Share": "Share", "Shell": "Shell", "Shell Command": "Shell Command", "Shell Reference": "Shell Reference", @@ -2200,6 +2390,7 @@ "Successfully signed in to {0}": "Successfully signed in to {0}", "Successfully signed in to tenant: {0}": "Successfully signed in to tenant: {0}", "Suggest a Feature": "Suggest a Feature", + "Suggested command": "Suggested command", "Switch Docker to Linux containers, then check again.": "Switch Docker to Linux containers, then check again.", "Switch to the new \"Connections View\"…": "Switch to the new \"Connections View\"…", "Table View": "Table View", @@ -2245,6 +2436,7 @@ "The {0} stage could not complete execution.\n\nReview the error message and query structure for potential issues.": "The {0} stage could not complete execution.\n\nReview the error message and query structure for potential issues.", "The account management flow has completed.\n\nPlease try Service Discovery again to see your available subscriptions.": "The account management flow has completed.\n\nPlease try Service Discovery again to see your available subscriptions.", "The active Docker context is unavailable. Select or repair a valid context, then check again.": "The active Docker context is unavailable. Select or repair a valid context, then check again.", + "The analysis failed. See the details on the page.": "The analysis failed. See the details on the page.", "The Azure resource reports that in-region high availability is disabled.": "The Azure resource reports that in-region high availability is disabled.", "The Azure resource reports that in-region high availability is enabled.": "The Azure resource reports that in-region high availability is enabled.", "The built-in _id index cannot be copied.": "The built-in _id index cannot be copied.", @@ -2278,6 +2470,9 @@ "The copied indexes are no longer supported by Copy/Paste Indexes: {0}. Copy supported indexes instead.": "The copied indexes are no longer supported by Copy/Paste Indexes: {0}. Copy supported indexes instead.", "The copied indexes no longer exist: {0}. Copy the indexes again.": "The copied indexes no longer exist: {0}. Copy the indexes again.", "The copyable secondary indexes from collection \"{0}\" are ready to paste.": "The copyable secondary indexes from collection \"{0}\" are ready to paste.", + "The count examined {0} documents. Collection size is unavailable; the rating uses index access and examined documents, not returned-document selectivity.": "The count examined {0} documents. Collection size is unavailable; the rating uses index access and examined documents, not returned-document selectivity.", + "The count examined {0}% of the collection. The rating uses index access and examined documents, not returned-document selectivity.": "The count examined {0}% of the collection. The rating uses index access and examined documents, not returned-document selectivity.", + "The count result from the execution stage nCounted field, not the number of documents returned by the count command.": "The count result from the execution stage nCounted field, not the number of documents returned by the count command.", "The credential for this project was rejected.": "The credential for this project was rejected.", "The credential identity cannot be changed": "The credential identity cannot be changed", "The credential is signed in but lacks access to this project. Review its roles and IP access list in MongoDB Atlas.": "The credential is signed in but lacks access to this project. Review its roles and IP access list in MongoDB Atlas.", @@ -2303,6 +2498,7 @@ "The docker command is not on PATH, so no further probe could run.": "The docker command is not on PATH, so no further probe could run.", "The docker command was located and run to read its version.": "The docker command was located and run to read its version.", "The Docker readiness check failed.": "The Docker readiness check failed.", + "The document count shows how much of the collection the query reads.": "The document count shows how much of the collection the query reads.", "The document field that stores the embedding array. Only one vector is indexed per path.": "The document field that stores the embedding array. Only one vector is indexed per path.", "The document names every database and collection on this cluster and describes the servers behind it, including their addresses. It contains no documents, queries or credentials. Review it before sharing.": "The document names every database and collection on this cluster and describes the servers behind it, including their addresses. It contains no documents, queries or credentials. Review it before sharing.", "The document with the _id \"{0}\" has been saved.": "The document with the _id \"{0}\" has been saved.", @@ -2357,15 +2553,19 @@ "The official image repository is fixed.": "The official image repository is fixed.", "The operating system and CPU architecture the daemon builds and runs containers for.": "The operating system and CPU architecture the daemon builds and runs containers for.", "The percentage of your collection this query returns. Could not be determined for this query.": "The percentage of your collection this query returns. Could not be determined for this query.", + "The plan lists an index scan on _id_, but no query condition is applied in that index. DocumentDB reads the documents in _id order so the results can be returned in batches, and checks every document against the filter afterwards. This is a full collection scan.\n\nKeys Examined counts only the documents that passed the filter, not every index entry that was read.": "The plan lists an index scan on _id_, but no query condition is applied in that index. DocumentDB reads the documents in _id order so the results can be returned in batches, and checks every document against the filter afterwards. This is a full collection scan.\n\nKeys Examined counts only the documents that passed the filter, not every index entry that was read.", "The playground file is empty. Add some code to run.": "The playground file is empty. Add some code to run.", "The port-forward tunnel to {target} looks active, but the service did not answer. The pod behind it may have restarted or been rescheduled.": "The port-forward tunnel to {target} looks active, but the service did not answer. The pod behind it may have restarted or been rescheduled.", "The query returned no documents.\n\nNo document fetching was needed because no documents matched the filter criteria.": "The query returned no documents.\n\nNo document fetching was needed because no documents matched the filter criteria.", "The requested managed identity is not assigned to this machine.": "The requested managed identity is not assigned to this machine.", + "The result of explain(\"executionStats\"). Every rating and the plan diagram are based on it.": "The result of explain(\"executionStats\"). Every rating and the plan diagram are based on it.", + "The same report, formatted for email, Teams and Word": "The same report, formatted for email, Teams and Word", "The selected {0} index is not supported by Copy/Paste Indexes.": "The selected {0} index is not supported by Copy/Paste Indexes.", "The selected authentication method is not supported.": "The selected authentication method is not supported.", "The selected connection has been removed.": "The selected connection has been removed.", "The selected folder has been removed.": "The selected folder has been removed.", "The selected item is not a database.": "The selected item is not a database.", + "The selected model may use premium requests.": "The selected model may use premium requests.", "The selected paths, and every field nested under them, are excluded. All other fields are indexed.": "The selected paths, and every field nested under them, are excluded. All other fields are indexed.", "The server hostname could not be resolved. The cluster may have been deleted or the URL may be incorrect.": "The server hostname could not be resolved. The cluster may have been deleted or the URL may be incorrect.", "The SORT stage exceeded the {0}MB memory limit.\n\n**Solutions:**\n1. Add .allowDiskUse(true) to allow disk-based sorting for large result sets\n2. Create an index matching the sort pattern: {1}\n3. Add filters to reduce the number of documents being sorted\n4. Increase server memory limit (requires server configuration)": "The SORT stage exceeded the {0}MB memory limit.\n\n**Solutions:**\n1. Add .allowDiskUse(true) to allow disk-based sorting for large result sets\n2. Create an index matching the sort pattern: {1}\n3. Add filters to reduce the number of documents being sorted\n4. Increase server memory limit (requires server configuration)", @@ -2392,6 +2592,7 @@ "These credentials cannot see any organizations yet. Check their project access and roles in MongoDB Atlas.": "These credentials cannot see any organizations yet. Check their project access and roles in MongoDB Atlas.", "These defaults work for most people. Change them only if you need to.": "These defaults work for most people. Change them only if you need to.", "They look like binary files rather than kubeconfigs.": "They look like binary files rather than kubeconfigs.", + "Thinking level and context size follow this model's settings in the VS Code Chat model picker.": "Thinking level and context size follow this model's settings in the VS Code Chat model picker.", "This {0} index cannot be copied by Copy/Paste Indexes.": "This {0} index cannot be copied by Copy/Paste Indexes.", "This action works through the tree view, and the cluster \"{cluster}\" could not be found there. Make sure the connection is still listed in the tree view, then try again.": "This action works through the tree view, and the cluster \"{cluster}\" could not be found there. Make sure the connection is still listed in the tree view, then try again.", "This action works through the tree view, and the collection \"{collection}\" is not loaded there yet. Expand \"{cluster}\" and then \"{database}\" in the tree view, then try again.": "This action works through the tree view, and the collection \"{collection}\" is not loaded there yet. Expand \"{cluster}\" and then \"{database}\" in the tree view, then try again.", @@ -2423,6 +2624,7 @@ "This DocumentDB link specifies a collection without a database. Add a database parameter or include the database in the connection string.": "This DocumentDB link specifies a collection without a database. Add a database parameter or include the database in the connection string.", "This DocumentDB Local link asks for an unsupported resource type. Supported resource types: {0}.": "This DocumentDB Local link asks for an unsupported resource type. Supported resource types: {0}.", "This DocumentDB Local link has an invalid path. Use /local or /local/documentdb.": "This DocumentDB Local link has an invalid path. Use /local or /local/documentdb.", + "This explain does not expose nCounted; the count result is unavailable, not zero.": "This explain does not expose nCounted; the count result is unavailable, not zero.", "This extension can only connect with a username and a password.": "This extension can only connect with a username and a password.", "This field is not set": "This field is not set", "This functionality requires installing the Azure Account extension.": "This functionality requires installing the Azure Account extension.", @@ -2441,6 +2643,7 @@ "This operation is not supported as it would create a circular dependency and never terminate. Please select a different target collection or database.": "This operation is not supported as it would create a circular dependency and never terminate. Please select a different target collection or database.", "This operation is not supported.": "This operation is not supported.", "This operation will copy all documents from the source to the target collection.": "This operation will copy all documents from the source to the target collection.", + "This optimized plan does not expose a separate cursor boundary. Initial-stage returns, selectivity and rating are unavailable; later pipeline stages ({0}) are not rated. Execution time describes the optimized plan.": "This optimized plan does not expose a separate cursor boundary. Initial-stage returns, selectivity and rating are unavailable; later pipeline stages ({0}) are not rated. Execution time describes the optimized plan.", "This playground has no connection.": "This playground has no connection.", "This playground has no connection. Click to connect it to a database.": "This playground has no connection. Click to connect it to a database.", "This playground is connected to {0} / {1}.": "This playground is connected to {0} / {1}.", @@ -2462,6 +2665,7 @@ "Tier": "Tier", "Time for the most recent ping command to complete. Includes network and server response time, not query execution time.": "Time for the most recent ping command to complete. Includes network and server response time, not query execution time.", "Time since the server started, reported by its serverStatus command.": "Time since the server started, reported by its serverStatus command.", + "Time unavailable": "Time unavailable", "Tip: use .maxTimeMS() to increase the time limit for this query.": "Tip: use .maxTimeMS() to increase the time limit for this query.", "TLS/SSL certificate validation disabled": "TLS/SSL certificate validation disabled", "TLS/SSL Disabled": "TLS/SSL Disabled", @@ -2470,7 +2674,9 @@ "To use a different Client ID, sign out and add a new credential.": "To use a different Client ID, sign out and add a new credential.", "To use a different Public Key, sign out and add a new credential.": "To use a different Public Key, sign out and add a new credential.", "TODO: Share the steps needed to reliably reproduce the problem. Please include actual and expected results.": "TODO: Share the steps needed to reliably reproduce the problem. Please include actual and expected results.", + "Tokens": "Tokens", "Too many requests were made. Wait briefly, then try again.": "Too many requests were made. Wait briefly, then try again.", + "Tool calling": "Tool calling", "Topology": "Topology", "Total documents to import: {0}": "Total documents to import: {0}", "Total Indexes": "Total Indexes", @@ -2566,9 +2772,11 @@ "Use \"{0}\"": "Use \"{0}\"", "Use a custom index name.": "Use a custom index name.", "Use a different managed identity...": "Use a different managed identity...", + "Use a JSON array for an aggregate pipeline, or a JSON object for find/count.": "Use a JSON array for an aggregate pipeline, or a JSON object for find/count.", "Use existing": "Use existing", "Use the identity assigned to this machine": "Use the identity assigned to this machine", "Use the supplied client ID as a user-assigned managed identity": "Use the supplied client ID as a user-assigned managed identity", + "Used to name the collection in the prompt and in copied commands.": "Used to name the collection in the prompt and in copied commands.", "User": "User", "Username": "Username", "Username and password": "Username and password", @@ -2587,7 +2795,6 @@ "Usernames starting with “{0}” are reserved by DocumentDB. Pick a different one.": "Usernames starting with “{0}” are reserved by DocumentDB. Pick a different one.", "Uses the account you sign in with in Visual Studio Code": "Uses the account you sign in with in Visual Studio Code", "Using custom prompt template for {type} query generation: {path}": "Using custom prompt template for {type} query generation: {path}", - "Using custom prompt template for {type} query: {path}": "Using custom prompt template for {type} query: {path}", "Using existing port-forward on 127.0.0.1:{0} for {1}/{2}.": "Using existing port-forward on 127.0.0.1:{0} for {1}/{2}.", "Using existing resource group \"{0}\".": "Using existing resource group \"{0}\".", "Using node InternalIP for NodePort service — this address may not be reachable outside the cluster. Ensure the node is accessible from your machine.": "Using node InternalIP for NodePort service — this address may not be reachable outside the cluster. Ensure the node is accessible from your machine.", @@ -2610,8 +2817,10 @@ "Verifying folder can be deleted…": "Verifying folder can be deleted…", "Verifying move operation…": "Verifying move operation…", "Verifying with MongoDB Atlas": "Verifying with MongoDB Atlas", + "Versatile": "Versatile", "Version": "Version", "Very dissatisfied": "Very dissatisfied", + "Very high cost": "Very high cost", "Very low efficiency ratio": "Very low efficiency ratio", "Very satisfied": "Very satisfied", "Very slow execution": "Very slow execution", @@ -2670,11 +2879,16 @@ "We found a container using the DocumentDB Local name, but it very likely was not created by this extension, so we cannot open it.": "We found a container using the DocumentDB Local name, but it very likely was not created by this extension, so we cannot open it.", "We found an existing data volume after the image downloaded. Setup stopped to protect its data. Go back to Configure and try again.": "We found an existing data volume after the image downloaded. Setup stopped to protect its data. Go back to Configure and try again.", "We found an existing DocumentDB Local instance, but its saved credentials are unavailable. Without them, we cannot reopen or reuse the existing data, so you need to start fresh. Nothing has been changed yet. Starting fresh erases the existing instance and its data, then creates a new one.": "We found an existing DocumentDB Local instance, but its saved credentials are unavailable. Without them, we cannot reopen or reuse the existing data, so you need to start fresh. Nothing has been changed yet. Starting fresh erases the existing instance and its data, then creates a new one.", + "Welcome": "Welcome", + "What is sent to GitHub Copilot": "What is sent to GitHub Copilot", "What should setup do with the existing data?": "What should setup do with the existing data?", "What the Docker check found": "What the Docker check found", "What will happen in the Set up step": "What will happen in the Set up step", + "What you'll need": "What you'll need", "What's New": "What's New", + "When you analyze, the explain plan, query, statistics and indexes you provide are sent to the selected GitHub Copilot model. Values in filters are included as-is, so remove anything sensitive first.": "When you analyze, the explain plan, query, statistics and indexes you provide are sent to the selected GitHub Copilot model. Values in filters are included as-is, so remove anything sensitive first.", "Where do I find these values?": "Where do I find these values?", + "Where the plan came from": "Where the plan came from", "Where to save the exported documents?": "Where to save the exported documents?", "Where VS Code is running the extension, detected before Docker was contacted.": "Where VS Code is running the extension, detected before Docker was contacted.", "Why am I seeing this?": "Why am I seeing this?", @@ -2744,6 +2958,7 @@ "Your local connections have been moved to '{0}' in the Connections view.": "Your local connections have been moved to '{0}' in the Connections view.", "Your own DocumentDB": "Your own DocumentDB", "Your own username and password": "Your own username and password", + "Your query": "Your query", "Your query does not require sorting, which avoids additional processing overhead.": "Your query does not require sorting, which avoids additional processing overhead.", "Your query does not use an index.\n\nWhile not necessarily a problem for small collections, adding appropriate indexes can significantly improve query performance.": "Your query does not use an index.\n\nWhile not necessarily a problem for small collections, adding appropriate indexes can significantly improve query performance.", "Your query examined documents but found no matches.\n\nIf this is unexpected, verify your filter criteria. The query's resource usage is better assessed by the index usage and execution time badges.": "Your query examined documents but found no matches.\n\nIf this is unexpected, verify your filter criteria. The query's resource usage is better assessed by the index usage and execution time badges.", diff --git a/package-lock.json b/package-lock.json index 1c323b19f..82f695954 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-documentdb", - "version": "1.0.1", + "version": "1.0.2-new-query-insights", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-documentdb", - "version": "1.0.1", + "version": "1.0.2-new-query-insights", "license": "SEE LICENSE IN LICENSE.md", "workspaces": [ "packages/*" @@ -51,6 +51,7 @@ "bson": "^7.2.0", "denque": "~2.1.0", "es-toolkit": "~1.45.1", + "micromark": "~4.0.2", "monaco-editor": "~0.52.2", "mongodb": "^7.1.0", "mongodb-connection-string-url": "~3.0.2", diff --git a/package.json b/package.json index a9a9b32df..fe750bc6e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vscode-documentdb", - "version": "1.0.1", + "version": "1.0.2-new-query-insights", "releaseNotesUrl": "https://github.com/microsoft/vscode-documentdb/discussions/1012", "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "publisher": "ms-azuretools", @@ -193,6 +193,7 @@ "bson": "^7.2.0", "denque": "~2.1.0", "es-toolkit": "~1.45.1", + "micromark": "~4.0.2", "monaco-editor": "~0.52.2", "mongodb": "^7.1.0", "mongodb-connection-string-url": "~3.0.2", @@ -390,6 +391,12 @@ "title": "New Local Connection…", "icon": "$(add)" }, + { + "//": "[Query Insights] Open the connectionless explorer proof of concept", + "category": "DocumentDB", + "command": "vscode-documentdb.command.queryInsights.exploreWithOwnInput", + "title": "Query Insights: Explore with Your Own Input (POC)" + }, { "//": "[Local Quick Start] Open the DocumentDB Local setup webview", "category": "DocumentDB", diff --git a/src/commands/llmEnhancedCommands/indexAdvisorCommands.test.ts b/src/commands/llmEnhancedCommands/indexAdvisorCommands.test.ts new file mode 100644 index 000000000..01940a92d --- /dev/null +++ b/src/commands/llmEnhancedCommands/indexAdvisorCommands.test.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + CommandType, + optimizeQuery, + optimizeQueryStreaming, + type QueryOptimizationContext, +} from './indexAdvisorCommands'; + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getAvailableModels: vi.fn(), + sendMessage: vi.fn(), + streamMessage: vi.fn(), + getTemplate: vi.fn(), +})); + +vi.mock('vscode', () => ({ + LanguageModelChatMessage: { User: (text: string): { text: string } => ({ text }) }, + l10n: { t: (message: string): string => message }, +})); +vi.mock('@microsoft/vscode-azext-utils', () => ({ + UserCancelledError: class extends Error {}, +})); +vi.mock('../../documentdb/ClusterSession', () => ({ ClusterSession: { getSession: mocks.getSession } })); +vi.mock('../../extensionVariables', () => ({ ext: { outputChannel: { trace: vi.fn() } } })); +vi.mock('../../services/ai/modelSelection', () => ({ getAvailableModels: mocks.getAvailableModels })); +vi.mock('../../services/copilotService', () => ({ + CopilotService: { sendMessage: mocks.sendMessage, streamMessage: mocks.streamMessage }, +})); +vi.mock('../../services/promptTemplateService', () => ({ + PromptTemplateService: { getIndexAdvisorPromptTemplate: mocks.getTemplate }, +})); +vi.mock('./promptTemplates', () => ({ getLastPromptSource: (): string => 'custom-file' })); + +function actionContext(): IActionContext { + return { telemetry: { properties: {}, measurements: {} } } as IActionContext; +} + +function providedContext(): QueryOptimizationContext { + return { + sessionId: '', + databaseName: 'test', + collectionName: 'items', + commandType: CommandType.Find, + queryObject: { filter: { status: 'active' } }, + executionPlan: { executionStats: { nReturned: 1 } }, + providedData: { clusterInfo: { domainInfo_isAzure: 'false' } }, + modelId: 'picked-model', + staticAnalysisSummary: 'Static verdict', + }; +} + +describe('Query Insights optimization inputs', () => { + const response = { + text: '{}', + modelId: 'picked-model', + modelFamily: 'picked-model', + modelDisplayName: 'Picked model', + modelVersion: '1', + durationMs: 1, + }; + + beforeEach(() => { + vi.resetAllMocks(); + mocks.getAvailableModels.mockResolvedValue({ models: [{ id: 'picked-model' }] }); + mocks.getTemplate.mockResolvedValue('Local prompt'); + mocks.sendMessage.mockResolvedValue(response); + }); + + it('builds exactly three messages without a session or fetching missing optional statistics', async () => { + const context = actionContext(); + await optimizeQuery(context, providedContext()); + + expect(mocks.getSession).not.toHaveBeenCalled(); + const [messages, options] = mocks.sendMessage.mock.calls[0] as [{ text: string }[], unknown]; + expect(messages).toHaveLength(3); + expect(messages[0].text).toBe('Local prompt'); + expect(messages[1].text).toContain('"status": "active"'); + expect(messages[2].text).toContain('**Collection_Stats**: N/A'); + expect(messages[2].text).toContain('**Indexes_Stats**: N/A'); + expect(messages[2].text).toContain('"nReturned": 1'); + expect(messages[2].text).toContain('Static verdict'); + expect(options).toMatchObject({ modelSelection: 'user', modelId: 'picked-model' }); + expect(context.telemetry.properties.connectionless).toBe('true'); + }); + + it('requires an execution plan in provided-data mode', async () => { + await expect( + optimizeQuery(actionContext(), { ...providedContext(), executionPlan: undefined }), + ).rejects.toThrow(/execution plan is required/); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + expect(mocks.getSession).not.toHaveBeenCalled(); + }); + + it('uses the same provided-data messages and selection for streaming', async () => { + mocks.streamMessage.mockReturnValue({ fragments: [], completion: Promise.resolve(response) }); + const handle = await optimizeQueryStreaming(actionContext(), providedContext()); + await handle.completion; + await optimizeQuery(actionContext(), providedContext()); + expect(mocks.streamMessage).toHaveBeenCalledWith(...mocks.sendMessage.mock.calls[0]); + }); + + it('keeps the connected preloaded path and its statistics unchanged', async () => { + const input = providedContext(); + const client = { + getClusterMetadata: vi.fn().mockResolvedValue({ domainInfo_isAzure: 'false' }), + getCollectionStats: vi.fn().mockResolvedValue({ count: 42 }), + listIndexes: vi.fn().mockResolvedValue([]), + getIndexStats: vi.fn().mockResolvedValue([]), + }; + mocks.getSession.mockReturnValue({ getClient: () => client }); + await optimizeQuery(actionContext(), { ...input, sessionId: 'session', providedData: undefined }); + expect(mocks.getSession).toHaveBeenCalledWith('session'); + expect(client.getCollectionStats).toHaveBeenCalledWith('test', 'items'); + expect(client.listIndexes).toHaveBeenCalledWith('test', 'items'); + expect(mocks.sendMessage).toHaveBeenCalled(); + }); +}); diff --git a/src/commands/llmEnhancedCommands/indexAdvisorCommands.ts b/src/commands/llmEnhancedCommands/indexAdvisorCommands.ts index cdaf10e8c..5c7533f94 100644 --- a/src/commands/llmEnhancedCommands/indexAdvisorCommands.ts +++ b/src/commands/llmEnhancedCommands/indexAdvisorCommands.ts @@ -12,6 +12,7 @@ import { ClusterSession } from '../../documentdb/ClusterSession'; import { type CollectionStats, type IndexStats } from '../../documentdb/LlmEnhancedFeatureApis'; import { type ClusterMetadata } from '../../documentdb/utils/getClusterMetadata'; import { ext } from '../../extensionVariables'; +import { getAvailableModels } from '../../services/ai/modelSelection'; import { CopilotService, type CopilotTokenUsage } from '../../services/copilotService'; import { PromptTemplateService } from '../../services/promptTemplateService'; import { getLastPromptSource, type FilledPromptResult } from './promptTemplates'; @@ -70,6 +71,13 @@ export interface QueryOptimizationContext { staticAnalysisSummary?: string; // AbortSignal for cancellation support signal?: AbortSignal; + // POC: explicit model id from the model picker (falls back to the saved choice / first model) + modelId?: string; + // POC: connectionless mode. When set, no ClusterSession is used; all inputs come from here + // together with `executionPlan`, `collectionStats` and `indexStats`. + providedData?: { + clusterInfo: ClusterMetadata; + }; } /** @@ -95,6 +103,8 @@ export interface OptimizationResult { // request. Optional fields (see CopilotTokenUsage) — may be undefined when // the model rejects countTokens or the request is cancelled. usage?: CopilotTokenUsage; + // The data messages sent with the request (query + context), for export. + modelInputs?: OptimizationModelInputs; } /** @@ -124,6 +134,21 @@ interface PreparedOptimizationRequest { * on the post-LLM telemetry call. */ promptTotalLength: number; + /** The data messages exactly as sent (never the instruction prompt). */ + modelInputs: OptimizationModelInputs; +} + +/** + * What the model received besides the instruction prompt, for the user-facing + * "copy as Markdown" export and debugging. + */ +export interface OptimizationModelInputs { + /** Second message: the user's query. */ + userQuery: string; + /** Third message: cluster info, collection/index stats, execution stats, static analysis. */ + contextData: string; + /** Where the instruction prompt came from (built-in resource, local resource, custom file, …). */ + promptSource: string; } /** @@ -392,6 +417,8 @@ export async function optimizeQuery( const response = await CopilotService.sendMessage(prepared.messages, { signal: queryContext.signal, featureSource: 'queryInsights', + modelSelection: 'user', + modelId: queryContext.modelId, }); return finalizeOptimizationResponse(context, prepared, response); @@ -431,6 +458,8 @@ export async function optimizeQueryStreaming( const streamHandle = CopilotService.streamMessage(prepared.messages, { signal: queryContext.signal, featureSource: 'queryInsights', + modelSelection: 'user', + modelId: queryContext.modelId, }); const completion: Promise = streamHandle.completion.then((response) => @@ -469,8 +498,8 @@ async function prepareOptimizationRequest( throw new UserCancelledError('AbortSignal'); } - // Check if Copilot is available - const copilotAvailable = await CopilotService.isAvailable(); + // Check if any Copilot model is available (POC: the user may pick a non-utility model) + const copilotAvailable = (await getAvailableModels()).models.length > 0; if (!copilotAvailable) { throw new Error( l10n.t( @@ -479,6 +508,10 @@ async function prepareOptimizationRequest( ); } + if (queryContext.providedData) { + return buildProvidedDataRequest(context, queryContext, queryContext.providedData.clusterInfo); + } + let explainResult: unknown; let collectionStats: CollectionStats | undefined; let indexes: IndexStats[] | undefined; @@ -709,7 +742,7 @@ async function prepareOptimizationRequest( // Fill the prompt template const commandType = queryContext.commandType; - const { craftedPrompt, userQuery, contextData } = await fillPromptTemplate( + const filledPrompt = await fillPromptTemplate( commandType, queryContext, collectionStats, @@ -720,6 +753,45 @@ async function prepareOptimizationRequest( queryContext.staticAnalysisSummary, ); + return buildOptimizationMessages(context, queryContext, filledPrompt); +} + +async function buildProvidedDataRequest( + context: IActionContext, + queryContext: QueryOptimizationContext, + clusterInfo: ClusterMetadata, +): Promise { + if (queryContext.executionPlan === undefined || queryContext.executionPlan === null) { + throw new Error(vscode.l10n.t('An execution plan is required for connectionless query optimization.')); + } + + context.telemetry.properties.connectionless = 'true'; + context.telemetry.properties.commandType = queryContext.commandType; + context.telemetry.properties.isAzure = clusterInfo.domainInfo_isAzure || 'false'; + context.telemetry.properties.azureApi = clusterInfo.domainInfo_api || 'unknown'; + context.telemetry.properties.hasPreloadedExecutionPlan = 'true'; + context.telemetry.properties.hasPreloadedCollectionStats = queryContext.collectionStats ? 'true' : 'false'; + context.telemetry.properties.hasPreloadedIndexStats = queryContext.indexStats ? 'true' : 'false'; + context.telemetry.properties.hasCollectionStats = queryContext.collectionStats ? 'true' : 'false'; + context.telemetry.measurements.indexCount = queryContext.indexStats?.length ?? 0; + + const filledPrompt = await fillPromptTemplate( + queryContext.commandType, + queryContext, + queryContext.collectionStats, + queryContext.indexStats, + JSON.stringify(queryContext.executionPlan, null, 2), + clusterInfo, + queryContext.staticAnalysisSummary, + ); + return buildOptimizationMessages(context, queryContext, filledPrompt); +} + +function buildOptimizationMessages( + context: IActionContext, + queryContext: QueryOptimizationContext, + { craftedPrompt, userQuery, contextData }: FilledPromptResult, +): PreparedOptimizationRequest { // Track prompt source and static analysis inclusion context.telemetry.properties.promptSource = getLastPromptSource(); context.telemetry.properties.hasStaticAnalysisSummary = queryContext.staticAnalysisSummary ? 'true' : 'false'; @@ -738,6 +810,7 @@ async function prepareOptimizationRequest( return { messages, promptTotalLength: craftedPrompt.length + userQuery.length + contextData.length, + modelInputs: { userQuery, contextData, promptSource: getLastPromptSource() }, }; } @@ -814,6 +887,7 @@ function finalizeOptimizationResponse( modelDisplayName: response.modelDisplayName, modelVersion: response.modelVersion, usage: response.usage, + modelInputs: prepared.modelInputs, }; } diff --git a/src/commands/llmEnhancedCommands/promptTemplates.test.ts b/src/commands/llmEnhancedCommands/promptTemplates.test.ts index 0b9dabc08..fff516ef8 100644 --- a/src/commands/llmEnhancedCommands/promptTemplates.test.ts +++ b/src/commands/llmEnhancedCommands/promptTemplates.test.ts @@ -3,16 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ext } from '../../extensionVariables'; import { AGGREGATE_QUERY_PROMPT_TEMPLATE, COUNT_QUERY_PROMPT_TEMPLATE, CRITICAL_JSON_REMINDER, FIND_QUERY_PROMPT_TEMPLATE, + LOCAL_INDEX_ADVISOR_PROMPT_FILE, buildIndexAdvisorPrompt, createPriorityDeclaration, createSecurityInstructions, + getLastPromptSource, getQueryTypeConfig, loadPromptBody, } from './promptTemplates'; @@ -289,6 +295,46 @@ describe('promptTemplates', () => { }); }); + describe('buildIndexAdvisorPrompt with a packaged local prompt', () => { + let extensionPath: string; + const originalContext = ext.context; + + beforeEach(() => { + extensionPath = fs.mkdtempSync(path.join(os.tmpdir(), 'qi-prompt-')); + fs.mkdirSync(path.join(extensionPath, 'resources', 'prompts', 'local'), { recursive: true }); + fs.writeFileSync( + path.join(extensionPath, 'resources', 'prompts', 'index-advisor-count.prompt.md'), + 'BUILT_IN_BODY', + ); + ext.context = { extensionPath } as unknown as typeof ext.context; + }); + + afterEach(() => { + ext.context = originalContext; + fs.rmSync(extensionPath, { recursive: true, force: true }); + }); + + it('should use the built-in resource when no local prompt is packaged', () => { + const result = buildIndexAdvisorPrompt('count', 'Test Role', ['msg1'], 'task', 'INLINE_FALLBACK'); + expect(result).toContain('BUILT_IN_BODY'); + expect(result).toContain('## PRIORITY DECLARATION'); + expect(getLastPromptSource()).toBe('resource-file'); + }); + + it('should return the local prompt as-is for every command type', () => { + fs.writeFileSync( + path.join(extensionPath, 'resources', 'prompts', LOCAL_INDEX_ADVISOR_PROMPT_FILE), + 'LOCAL_PROMPT', + ); + for (const commandType of ['find', 'aggregate', 'count']) { + expect(buildIndexAdvisorPrompt(commandType, 'Test Role', ['msg1'], 'task', 'INLINE_FALLBACK')).toBe( + 'LOCAL_PROMPT', + ); + expect(getLastPromptSource()).toBe('local-resource-file'); + } + }); + }); + describe('Template structural integrity', () => { const templates = [ { name: 'FIND', template: FIND_QUERY_PROMPT_TEMPLATE }, diff --git a/src/commands/llmEnhancedCommands/promptTemplates.ts b/src/commands/llmEnhancedCommands/promptTemplates.ts index 75240604d..98b165e87 100644 --- a/src/commands/llmEnhancedCommands/promptTemplates.ts +++ b/src/commands/llmEnhancedCommands/promptTemplates.ts @@ -58,6 +58,14 @@ const INDEX_ADVISOR_PROMPT_RESOURCE_FILES: Record = { count: 'index-advisor-count.prompt.md', }; +/** + * Optional local prompt within `resources/prompts/`. The folder is gitignored, so the file is + * never committed, but the build copies `resources/` and packaging includes it when present. + * It replaces the built-in index advisor prompt for every command type and must be complete, + * including its own priority and security sections. + */ +export const LOCAL_INDEX_ADVISOR_PROMPT_FILE = 'local/query-insights.prompt.md'; + /** * Cache for loaded prompt bodies to avoid re-reading files on every call. */ @@ -98,7 +106,8 @@ export function loadPromptBody( /** * Builds a complete index advisor prompt by composing the dynamic header * (PRIORITY DECLARATION + SECURITY INSTRUCTIONS) with the body loaded from - * a resource file. Falls back to the inline constant if the file cannot be loaded. + * a resource file. A packaged {@link LOCAL_INDEX_ADVISOR_PROMPT_FILE} takes precedence and is + * returned as-is. Falls back to the inline constant if the file cannot be loaded. * * @param commandType - 'find' | 'aggregate' | 'count' * @param role - The role string for the priority declaration @@ -120,6 +129,12 @@ export function buildIndexAdvisorPrompt( return inlineFallback; } + const local = loadPromptBody(LOCAL_INDEX_ADVISOR_PROMPT_FILE); + if ('body' in local) { + lastPromptSource = 'local-resource-file'; + return local.body; + } + const result = loadPromptBody(resourceFile); if ('fallbackReason' in result) { lastPromptSource = `inline-fallback-${result.fallbackReason}`; @@ -140,6 +155,7 @@ ${result.body}`; */ export type PromptSource = | 'resource-file' + | 'local-resource-file' | 'inline-fallback' | 'inline-fallback-no-context' | 'inline-fallback-read-error' diff --git a/src/commands/queryInsights/exploreWithOwnInput.ts b/src/commands/queryInsights/exploreWithOwnInput.ts new file mode 100644 index 000000000..77c7db945 --- /dev/null +++ b/src/commands/queryInsights/exploreWithOwnInput.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { openQueryInsightsStandalone } from '../../webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneController'; +import { queryInsightsPrefillSchema } from '../../webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneInput'; + +export function exploreWithOwnInput(_context: IActionContext, prefill?: unknown): void { + const parsed = queryInsightsPrefillSchema.safeParse(prefill); + openQueryInsightsStandalone(parsed.success ? parsed.data : {}); +} diff --git a/src/documentdb/ClustersExtension.ts b/src/documentdb/ClustersExtension.ts index d1abbb7cf..1964763ae 100644 --- a/src/documentdb/ClustersExtension.ts +++ b/src/documentdb/ClustersExtension.ts @@ -77,6 +77,7 @@ import { playgroundOpenQueryInShell } from '../commands/playground/playgroundOpe import { runAll } from '../commands/playground/runAll'; import { runSelected } from '../commands/playground/runSelected'; import { scanCollectionSchema } from '../commands/playground/scanCollectionSchema'; +import { exploreWithOwnInput } from '../commands/queryInsights/exploreWithOwnInput'; import { refreshTreeElement } from '../commands/refreshTreeElement/refreshTreeElement'; import { refreshView } from '../commands/refreshView/refreshView'; import { removeConnection } from '../commands/removeConnection/removeConnection'; @@ -661,6 +662,10 @@ export class ClustersExtension implements vscode.Disposable { 'vscode-documentdb.command.localQuickStart.open', withCommandCorrelation(openLocalQuickStart), ); + registerCommand( + 'vscode-documentdb.command.queryInsights.exploreWithOwnInput', + withCommandCorrelation(exploreWithOwnInput), + ); registerCommand( 'vscode-documentdb.command.localQuickStart.start', withCommandCorrelation(startQuickStartInstance), diff --git a/src/documentdb/queryInsights/ExplainPlanAnalyzer.nonArrayStages.test.ts b/src/documentdb/queryInsights/ExplainPlanAnalyzer.nonArrayStages.test.ts new file mode 100644 index 000000000..8bed3cf78 --- /dev/null +++ b/src/documentdb/queryInsights/ExplainPlanAnalyzer.nonArrayStages.test.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Document } from 'mongodb'; +import { describe, expect, it } from 'vitest'; +import { fixupDocumentDbExplain } from '../utils/fixupDocumentDbExplain'; +import { analyzeExplainForStage2 } from './analyzeExplainForStage2'; +import { ExplainPlanAnalyzer } from './ExplainPlanAnalyzer'; +import { buildStaticAnalysisSummary } from './staticAnalysisSummary'; + +function explainWith(executionStages: Document): Document { + return { + queryPlanner: { namespace: 'db.coll', winningPlan: { stage: 'COLLSCAN' } }, + executionStats: { + executionSuccess: true, + nReturned: 5, + executionTimeMillis: 12, + totalKeysExamined: 0, + totalDocsExamined: 100, + executionStages, + }, + }; +} + +describe('ExplainPlanAnalyzer with non-array child stages', () => { + it.each([ + ['shards as an object', { stage: 'COLLSCAN', nReturned: 5, shards: { s0: { stage: 'COLLSCAN' } } }], + ['shards as a number', { stage: 'COLLSCAN', nReturned: 5, shards: 2 }], + ['inputStages as an object', { stage: 'OR', nReturned: 5, inputStages: { a: { stage: 'COLLSCAN' } } }], + ])('analyzes the plan when a stage has %s', (_label, executionStages) => { + const explain = explainWith(executionStages); + + expect(() => ExplainPlanAnalyzer.analyzeExecutionStats(explain)).not.toThrow(); + const analyzed = ExplainPlanAnalyzer.analyzeExecutionStats(explain); + expect(analyzed.nReturned).toBe(5); + expect(analyzed.totalDocsExamined).toBe(100); + expect(() => analyzeExplainForStage2(explain, 1000, {}, 'find')).not.toThrow(); + }); + + it('keeps the original explain untouched in rawStats', () => { + const executionStages = { stage: 'COLLSCAN', shards: { s0: { stage: 'COLLSCAN' } } }; + const explain = explainWith(executionStages); + + const analyzed = ExplainPlanAnalyzer.analyzeExecutionStats(explain); + + expect(analyzed.rawStats).toBe(explain); + expect(executionStages.shards).toEqual({ s0: { stage: 'COLLSCAN' } }); + }); +}); + +/** Azure DocumentDB plan where `_id_` only provides order: `indexUsage` is `{}`, not an array. */ +function documentDbIdOrderPlan(): Document { + const ixscan = { + stage: 'IXSCAN', + nReturned: 0, + executionTimeMillis: 0.614, + executionStartAtTimeMillis: 0.614, + totalDocsExamined: 1000, + indexName: '_id_', + indexUsage: {}, + totalKeysExamined: 0, + totalDocsRemovedByRuntimeFilter: 1000, + numBlocksFromCache: 67, + }; + const runtimeFilterSet = [{ 'address.building': { $gt: 30000 } }, { $unknown: {} }]; + return { + explainVersion: 2, + command: + "db.runCommand({explain: { 'find' : 'restaurants', 'filter' : { 'address.building' : { '$gt' : 30000 } } }})", + queryPlanner: { + namespace: 'MyDatabase.restaurants', + winningPlan: { + stage: 'FETCH', + cursorScanType: 'streamingPrimaryKey', + runtimeFilterSet, + estimatedTotalKeysExamined: 167, + inputStage: { + stage: 'IXSCAN', + cursorScanType: 'streamingPrimaryKey', + indexName: '_id_', + direction: 'Forward', + indexUsage: {}, + runtimeFilterSet, + estimatedTotalKeysExamined: 167, + }, + }, + }, + executionStats: { + nReturned: 0, + executionTimeMillis: 0.614, + executionStartAtTimeMillis: 0.614, + totalDocsExamined: 1000, + totalKeysExamined: 0, + executionStages: { + stage: 'FETCH', + nReturned: 0, + executionTimeMillis: 0.614, + totalDocsExamined: 1000, + totalKeysExamined: 0, + totalDocsRemovedByRuntimeFilter: 1000, + numBlocksFromCache: 67, + inputStage: ixscan, + }, + }, + ok: 1, + }; +} + +describe('DocumentDB plan with indexUsage as an empty object', () => { + const filter = { 'address.building': { $gt: 30000 } }; + + it.each([ + ['as returned by the server', (): Document => documentDbIdOrderPlan()], + [ + 'after the Azure DocumentDB fixup', + (): Document => { + const plan = documentDbIdOrderPlan(); + fixupDocumentDbExplain(plan, { domainInfo_isAzure: 'true', domainInfo_api: 'vCore' }); + return plan; + }, + ], + ])('completes Stage 2 analysis %s', (_label, makePlan) => { + const stage2 = analyzeExplainForStage2(makePlan(), 1000, filter, 'find'); + + expect(stage2.documentsReturned).toBe(0); + expect(stage2.totalDocsExamined).toBe(1000); + // The `_id_` walk is a primary-key order scan, so it is reported as a collection scan. + expect(stage2.usedIndexNames).toEqual([]); + expect(stage2.hadCollectionScan).toBe(true); + expect(() => buildStaticAnalysisSummary(stage2, 1000)).not.toThrow(); + }); +}); diff --git a/src/documentdb/queryInsights/ExplainPlanAnalyzer.ts b/src/documentdb/queryInsights/ExplainPlanAnalyzer.ts index d07015188..529c748d3 100644 --- a/src/documentdb/queryInsights/ExplainPlanAnalyzer.ts +++ b/src/documentdb/queryInsights/ExplainPlanAnalyzer.ts @@ -7,6 +7,7 @@ import { ExplainPlan } from '@mongodb-js/explain-plan-helper'; import * as l10n from '@vscode/l10n'; import { type Document } from 'mongodb'; import { type ExtendedStageInfo } from '../../webviews/documentdb/collectionView/types/queryInsights'; +import { refineDocumentDbPlanFacts, type PlanAccessFacts, type PlanRefinementId } from './documentDbPlanRefinements'; // ============================================================================ // Index strategy advisory thresholds @@ -31,6 +32,10 @@ const SCORE_ORDER: ReadonlyArray => + typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype; + + const normalize = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(normalize); + } + if (!isPlainObject(value)) { + return value; + } + const copy: Record = {}; + for (const [key, child] of Object.entries(value)) { + copy[key] = normalize(child); + } + if (typeof value.stage === 'string') { + for (const key of ['shards', 'inputStages']) { + const children = copy[key]; + if (children === undefined || Array.isArray(children)) { + continue; + } + if (isPlainObject(children)) { + copy[key] = Object.values(children).filter(isPlainObject); + } else { + delete copy[key]; + } + } + } + return copy; + }; + + return normalize(explainResult) as Document; + } + /** * Analyzes explain("queryPlanner") output * Provides basic query characteristics without execution metrics @@ -115,13 +161,18 @@ export class ExplainPlanAnalyzer { */ public static analyzeQueryPlanner(explainResult: Document): QueryPlannerAnalysis { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument - const explainPlan = new ExplainPlan(explainResult as any); + const explainPlan = new ExplainPlan(this.toExplainPlanHelperInput(explainResult) as any); - // Extract metrics using helper methods + // Extract metrics using helper methods (first pass), then the DocumentDB corrections const usedIndexesInfo = explainPlan.usedIndexes || []; - const usedIndexes = usedIndexesInfo.map((idx) => (typeof idx === 'string' ? idx : idx.index || 'unknown')); - const isCollectionScan = explainPlan.isCollectionScan; - const isCovered = explainPlan.isCovered; + const extractedIndexes = usedIndexesInfo.map((idx) => (typeof idx === 'string' ? idx : idx.index || 'unknown')); + const { facts } = refineDocumentDbPlanFacts(explainResult, { + usedIndexes: extractedIndexes, + isCollectionScan: explainPlan.isCollectionScan, + isCovered: explainPlan.isCovered, + isIndexScan: extractedIndexes.length > 0 && !explainPlan.isCollectionScan, + }); + const { usedIndexes, isCollectionScan, isCovered } = facts; const hasInMemorySort = explainPlan.inMemorySort; const namespace = explainPlan.namespace; @@ -149,7 +200,7 @@ export class ExplainPlanAnalyzer { */ public static analyzeExecutionStats(explainResult: Document, queryFilter?: Document): ExecutionStatsAnalysis { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument - const explainPlan = new ExplainPlan(explainResult as any); + const explainPlan = new ExplainPlan(this.toExplainPlanHelperInput(explainResult) as any); // STEP 1: Check for execution errors FIRST const executionStats = explainResult.executionStats as Document | undefined; @@ -164,19 +215,49 @@ export class ExplainPlanAnalyzer { // Calculate efficiency ratio const efficiencyRatio = this.calculateEfficiencyRatio(nReturned, totalDocsExamined); - // Extract query characteristics + // Extract query characteristics (first pass: engine-neutral, via explain-plan-helper) const usedIndexesInfo = explainPlan.usedIndexes || []; - const usedIndexes = usedIndexesInfo.map((idx) => (typeof idx === 'string' ? idx : idx.index || 'unknown')); - const isCollectionScan = explainPlan.isCollectionScan; - const isCovered = explainPlan.isCovered; - const hasInMemorySort = explainPlan.inMemorySort; + const extracted: PlanAccessFacts = { + usedIndexes: usedIndexesInfo.map((idx) => (typeof idx === 'string' ? idx : idx.index || 'unknown')), + isCollectionScan: explainPlan.isCollectionScan, + isCovered: explainPlan.isCovered, + isIndexScan: false, + }; // Note: isIndexScan is derived from whether indexes are used - const isIndexScan = usedIndexes.length > 0 && !isCollectionScan; + extracted.isIndexScan = extracted.usedIndexes.length > 0 && !extracted.isCollectionScan; + + // Second pass: DocumentDB-specific corrections, applied before the rating is computed + const refined = refineDocumentDbPlanFacts(explainResult, extracted); + const { usedIndexes, isCollectionScan, isCovered, isIndexScan } = refined.facts; + const hasInMemorySort = explainPlan.inMemorySort; // Check if sorting is being performed (either in-memory or index-based) // We detect this by checking if there's a SORT stage in the execution plan const hasSorting = this.detectSortingInPlan(explainResult); + const performanceRating = executionError + ? this.createFailedQueryRating(executionError) + : this.calculatePerformanceRating( + executionTimeMillis, + efficiencyRatio, + hasInMemorySort, + hasSorting, + isIndexScan, + isCollectionScan, + queryFilter, + ); + if (!executionError && refined.diagnostics.length > 0) { + // Place the explanation right after the index usage badge it qualifies. + const indexBadge = performanceRating.diagnostics.findIndex( + (d) => d.diagnosticId === 'full_collection_scan', + ); + performanceRating.diagnostics.splice( + indexBadge >= 0 ? indexBadge + 1 : performanceRating.diagnostics.length, + 0, + ...refined.diagnostics, + ); + } + // Build response structure return { executionTimeMillis, @@ -189,19 +270,10 @@ export class ExplainPlanAnalyzer { isCovered, hasInMemorySort, isIndexScan, - performanceRating: executionError - ? this.createFailedQueryRating(executionError) - : this.calculatePerformanceRating( - executionTimeMillis, - efficiencyRatio, - hasInMemorySort, - hasSorting, - isIndexScan, - isCollectionScan, - queryFilter, - ), + performanceRating, rawStats: explainResult, executionError, + planRefinements: refined.refinements, }; } @@ -838,13 +910,14 @@ export class ExplainPlanAnalyzer { | Document | undefined; const ixscanExec = this.findStageInPlan(executionStages, 'IXSCAN'); - const indexUsage = ixscanExec?.indexUsage as Array<{ scanKeys?: string[] }> | undefined; + // DocumentDB reports `indexUsage: {}` (not an array) when the index only provides order. + const indexUsage: unknown = ixscanExec?.indexUsage; - if (indexUsage) { - for (const usage of indexUsage) { + if (Array.isArray(indexUsage)) { + for (const usage of indexUsage as Array<{ scanKeys?: unknown }>) { // Skip compound indexes — per-key cardinality check is not meaningful - if (usage.scanKeys && usage.scanKeys.length === 1) { - const scanKey = usage.scanKeys[0]; + if (Array.isArray(usage?.scanKeys) && usage.scanKeys.length === 1) { + const scanKey = String(usage.scanKeys[0]); // Parse: "key N: [(isInequality: false, estimatedEntryCount: 22074)]" const match = /estimatedEntryCount:\s*(\d+)/.exec(scanKey); if (match) { @@ -890,11 +963,12 @@ export class ExplainPlanAnalyzer { totalCollectionDocs: number | undefined, explainResult: Document, queryFilter?: Document, + options: IndexStrategyAdvisoryOptions = {}, ): void { const diagnostics = analysis.performanceRating.diagnostics; // --- Coverage badges (gated on index scan) --- - if (analysis.isIndexScan && totalCollectionDocs && totalCollectionDocs > 0) { + if (!options.skipCoverage && analysis.isIndexScan && totalCollectionDocs && totalCollectionDocs > 0) { const coverage = Math.min(analysis.nReturned / totalCollectionDocs, 1); if (coverage >= COVERAGE_HIGH_RETURN) { @@ -946,18 +1020,18 @@ export class ExplainPlanAnalyzer { ixscanExec && bitmapIndexName && (ixscanExec.indexName as string) === bitmapIndexName ? ixscanExec : undefined; - const indexUsage = correlatedExec?.indexUsage as Array<{ scanKeys?: string[] }> | undefined; + const indexUsage: unknown = correlatedExec?.indexUsage; // We require exactly one indexUsage entry with exactly one scanKey. // If there are multiple entries (e.g., mixed single/compound), we // conservatively treat the index as compound to avoid false demotions. const isSingleField = - indexUsage !== undefined && + Array.isArray(indexUsage) && indexUsage.length === 1 && - indexUsage[0].scanKeys !== undefined && - indexUsage[0].scanKeys.length === 1; + Array.isArray((indexUsage[0] as { scanKeys?: unknown } | undefined)?.scanKeys) && + (indexUsage[0] as { scanKeys: unknown[] }).scanKeys.length === 1; const coverage = - totalCollectionDocs && totalCollectionDocs > 0 + !options.skipCoverage && totalCollectionDocs && totalCollectionDocs > 0 ? Math.min(analysis.nReturned / totalCollectionDocs, 1) : undefined; @@ -988,7 +1062,7 @@ export class ExplainPlanAnalyzer { // Skip when efficiency is high (≥90%) — the index is clearly working well // regardless of individual key cardinality (e.g., compound indexes with a // low-cardinality prefix followed by selective fields). - if (analysis.isIndexScan && analysis.efficiencyRatio < 0.9) { + if (analysis.isIndexScan && (options.skipCoverage || analysis.efficiencyRatio < 0.9)) { const filter = queryFilter ?? ((explainResult.command as Document | undefined)?.filter as Document | undefined); const cardinalityResult = this.detectLowCardinalityIndex(explainResult, totalCollectionDocs, filter); @@ -1098,4 +1172,6 @@ export interface ExecutionStatsAnalysis { rawStats: Document; extendedStageInfo?: ExtendedStageInfo[]; executionError?: QueryExecutionError; + /** DocumentDB-specific corrections applied by the second analysis pass (empty for other engines). */ + planRefinements?: PlanRefinementId[]; } diff --git a/src/documentdb/queryInsights/StagePropertyExtractor.ts b/src/documentdb/queryInsights/StagePropertyExtractor.ts index d77509a57..6424d2d45 100644 --- a/src/documentdb/queryInsights/StagePropertyExtractor.ts +++ b/src/documentdb/queryInsights/StagePropertyExtractor.ts @@ -69,7 +69,7 @@ export class StagePropertyExtractor { if (stage.inputStage) { this.collectPlannerStages(stage.inputStage as Document, accumulator); } - if (stage.inputStages) { + if (Array.isArray(stage.inputStages)) { (stage.inputStages as Document[]).forEach((child: Document) => { this.collectPlannerStages(child, accumulator); }); @@ -99,12 +99,12 @@ export class StagePropertyExtractor { if (stage.inputStage) { this.traverseStages(stage.inputStage as Document, accumulator); } - if (stage.inputStages) { + if (Array.isArray(stage.inputStages)) { (stage.inputStages as Document[]).forEach((childStage: Document) => { this.traverseStages(childStage, accumulator); }); } - if (stage.shards) { + if (Array.isArray(stage.shards)) { (stage.shards as Document[]).forEach((shard: Document) => { this.traverseStages(shard, accumulator); }); diff --git a/src/documentdb/queryInsights/analysisProfiles.test.ts b/src/documentdb/queryInsights/analysisProfiles.test.ts new file mode 100644 index 000000000..c1d978dfd --- /dev/null +++ b/src/documentdb/queryInsights/analysisProfiles.test.ts @@ -0,0 +1,280 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { findCountedResult, getInitialExplainPlan } from './analysisProfiles'; +import { analyzeExplainForStage2 } from './analyzeExplainForStage2'; +import { ExplainPlanAnalyzer } from './ExplainPlanAnalyzer'; +import { StagePropertyExtractor } from './StagePropertyExtractor'; +import { transformStage2Response } from './transformations'; + +function cursorPlan(): Record { + return { + queryPlanner: { namespace: 'db.items', winningPlan: { stage: 'COLLSCAN' } }, + executionStats: { + executionSuccess: true, + executionTimeMillis: 12, + nReturned: 35, + totalDocsExamined: 100, + totalKeysExamined: 0, + executionStages: { stage: 'COLLSCAN', nReturned: 35, docsExamined: 100 }, + }, + }; +} + +describe('Command-aware Stage 2 analysis profiles', () => { + it('keeps the find transformation and advisories exactly as before', () => { + const explain = cursorPlan(); + const baseline = ExplainPlanAnalyzer.analyzeExecutionStats(explain, { status: 'active' }); + baseline.extendedStageInfo = StagePropertyExtractor.extractAllExtendedStageInfo( + (explain.executionStats as { executionStages: Record }).executionStages, + ); + StagePropertyExtractor.enrichWithQueryPlannerInfo( + baseline.extendedStageInfo, + (explain.queryPlanner as { winningPlan: Record }).winningPlan, + ); + ExplainPlanAnalyzer.addIndexStrategyAdvisories(baseline, 200, explain, { status: 'active' }); + expect(analyzeExplainForStage2(cursorPlan(), 200, { status: 'active' })).toEqual( + transformStage2Response(baseline, 200), + ); + }); + + it('analyzes cursor metrics and renders its stages, not the reduced aggregate output', () => { + const cursor = cursorPlan(); + const explain = { + stages: [ + { $cursor: cursor }, + { $group: { _id: '$status' }, nReturned: 1, executionTimeMillisEstimate: 500 }, + { $sort: { count: -1 }, nReturned: 1, executionTimeMillisEstimate: 700 }, + ], + }; + const response = analyzeExplainForStage2(explain, 200, { status: 'active' }, 'aggregate'); + expect(response.analysisProfile).toBe('aggregate'); + expect(response.documentsReturned).toBe(35); + expect(response.totalDocsExamined).toBe(100); + expect(response.executionTimeMs).toBe(12); + expect(response.efficiencyAnalysis.selectivity).toBe(17.5); + expect(response.stages.map((stage) => stage.stage)).toEqual(['COLLSCAN']); + expect(response.extendedStageInfo?.length).toBeGreaterThan(0); + expect(response.unassessedPipelineStages).toEqual(['$group', '$sort']); + expect(response.notApplicable).toContain('inMemorySort'); + expect(response.analysisNotes?.[0]).toContain('$group, $sort'); + expect( + response.efficiencyAnalysis.performanceRating.diagnostics.map((diagnostic) => diagnostic.diagnosticId), + ).not.toContain('no_sorting_required'); + expect((cursor.executionStats as { nReturned: number }).nReturned).toBe(35); + expect(getInitialExplainPlan(explain, 'aggregate')).toBe(cursor); + }); + + it('handles an optimized single-plan aggregate without inventing cursor stages', () => { + const explain = { + ...cursorPlan(), + command: { aggregate: 'items', pipeline: [{ $match: { status: 'active' } }] }, + }; + const response = analyzeExplainForStage2(explain, 200, { status: 'active' }, 'aggregate'); + expect(response.documentsReturned).toBe(35); + expect(response.stages[0].stage).toBe('COLLSCAN'); + expect(response.unassessedPipelineStages).toEqual([]); + expect(response.efficiencyAnalysis.performanceRating.diagnostics).toContainEqual( + expect.objectContaining({ diagnosticId: 'aggregate_initial_stage_only', type: 'neutral' }), + ); + }); + + it('keeps a known cursor SORT visible while warning that later pipeline stages are not rated', () => { + const cursor = cursorPlan(); + const planner = { winningPlan: { stage: 'SORT', inputStage: { stage: 'COLLSCAN' } } }; + const stats = { + ...(cursor.executionStats as Record), + executionStages: { + stage: 'SORT', + nReturned: 35, + inputStage: { stage: 'COLLSCAN', nReturned: 35 }, + }, + }; + const response = analyzeExplainForStage2( + { + stages: [ + { $cursor: { queryPlanner: planner, executionStats: stats } }, + { $sort: { status: 1 }, nReturned: 35 }, + ], + }, + 200, + {}, + 'aggregate', + ); + expect(response.efficiencyAnalysis.hasInMemorySort).toBe(true); + expect(response.notApplicable).not.toContain('inMemorySort'); + }); + + it('does not label an optimized GROUP result as initial cursor throughput or rate its selectivity', () => { + const base = cursorPlan(); + const response = analyzeExplainForStage2( + { + ...base, + queryPlanner: { winningPlan: { stage: 'GROUP', inputStage: { stage: 'COLLSCAN' } } }, + executionStats: { + ...(base.executionStats as Record), + nReturned: 1, + executionStages: { + stage: 'GROUP', + nReturned: 1, + inputStage: { stage: 'COLLSCAN', nReturned: 35, docsExamined: 100 }, + }, + }, + command: { + aggregate: 'items', + pipeline: [{ $match: { status: 'active' } }, { $group: { _id: '$status' } }], + }, + }, + 200, + { status: 'active' }, + 'aggregate', + ); + expect(response.notApplicable).toEqual( + expect.arrayContaining([ + 'documentsReturned', + 'selectivity', + 'examinedToReturnedRatio', + 'performanceRating', + ]), + ); + expect(response.efficiencyAnalysis.selectivity).toBeNull(); + expect(response.analysisNotes?.[0]).toContain('does not expose a separate cursor boundary'); + expect( + response.efficiencyAnalysis.performanceRating.diagnostics.some((diagnostic) => + diagnostic.diagnosticId.includes('efficiency_ratio'), + ), + ).toBe(false); + }); + + it.each([true, false])( + 'does not invent zero cursor time when only a pipeline estimate is available: %s', + (hasEstimate) => { + const cursor = cursorPlan(); + const stats = { ...(cursor.executionStats as Record) }; + delete stats.executionTimeMillis; + const response = analyzeExplainForStage2( + { + stages: [ + { $cursor: { ...cursor, executionStats: stats } }, + { + $group: { _id: null }, + nReturned: 1, + ...(hasEstimate ? { executionTimeMillisEstimate: 500 } : {}), + }, + ], + }, + 200, + {}, + 'aggregate', + ); + expect(response.documentsReturned).toBe(35); + expect(response.notApplicable).toContain('performanceRating'); + if (hasEstimate) { + expect(response.executionTimeMs).toBe(500); + expect(response.executionTimeScope).toBe('pipeline-estimate'); + expect(response.notApplicable).not.toContain('executionTime'); + } else { + expect(response.notApplicable).toContain('executionTime'); + } + expect( + response.efficiencyAnalysis.performanceRating.diagnostics.map((diagnostic) => diagnostic.diagnosticId), + ).not.toContain('fast_execution'); + }, + ); + + it('uses nCounted for COUNT_SCAN and does not punish an index-only count for nReturned=0', () => { + const response = analyzeExplainForStage2( + { + queryPlanner: { + namespace: 'db.items', + winningPlan: { + stage: 'COUNT', + inputStage: { stage: 'COUNT_SCAN', indexName: 'status_1', keyPattern: { status: 1 } }, + }, + }, + executionStats: { + executionSuccess: true, + executionTimeMillis: 1, + nReturned: 0, + totalDocsExamined: 0, + totalKeysExamined: 80, + executionStages: { + stage: 'COUNT', + nReturned: 0, + nCounted: 80, + inputStage: { + stage: 'COUNT_SCAN', + indexName: 'status_1', + keyPattern: { status: 1 }, + keysExamined: 80, + }, + }, + }, + }, + 100, + { status: 'active' }, + 'count', + ); + expect(response.documentsReturned).toBe(80); + expect(response.indexUsed).toBe(true); + expect(response.efficiencyAnalysis.performanceRating.score).toBe('excellent'); + expect(response.efficiencyAnalysis.selectivity).toBeNull(); + expect(response.notApplicable).toEqual( + expect.arrayContaining(['selectivity', 'fetchOverhead', 'inMemorySort', 'examinedToReturnedRatio']), + ); + const ids = response.efficiencyAnalysis.performanceRating.diagnostics.map( + (diagnostic) => diagnostic.diagnosticId, + ); + expect(ids).toContain('index_used'); + expect(ids).not.toContain('no_matching_documents'); + expect(ids).not.toContain('returns_majority_of_collection'); + expect(ids).not.toContain('low_filter_selectivity'); + expect(ids.some((id) => id.includes('efficiency_ratio'))).toBe(false); + }); + + it('rates a full-scan count by collection work even when nCounted=0', () => { + const response = analyzeExplainForStage2( + { + ...cursorPlan(), + executionStats: { + executionTimeMillis: 12, + nReturned: 0, + totalDocsExamined: 100, + totalKeysExamined: 0, + executionStages: { + stage: 'COUNT', + nCounted: 0, + inputStage: { stage: 'COLLSCAN', docsExamined: 100 }, + }, + }, + }, + 100, + { missing: true }, + 'count', + ); + expect(response.documentsReturned).toBe(0); + expect(response.notApplicable).not.toContain('documentsReturned'); + expect(response.efficiencyAnalysis.performanceRating.score).toBe('poor'); + expect( + response.efficiencyAnalysis.performanceRating.diagnostics.map((diagnostic) => diagnostic.diagnosticId), + ).not.toContain('no_matching_documents'); + expect(response.concerns.some((concern) => concern.includes('selectivity'))).toBe(false); + }); + + it('marks an absent nCounted as unavailable rather than claiming a zero result', () => { + const response = analyzeExplainForStage2(cursorPlan(), 100, {}, 'count'); + expect(response.notApplicable).toContain('documentsReturned'); + expect(response.analysisNotes?.some((note) => note.includes('unavailable, not zero'))).toBe(true); + }); + + it('sums per-shard counted results once when summary statistics also repeat shard data', () => { + const shards = [ + { executionStages: { stage: 'COUNT', nCounted: 30 } }, + { executionStages: { stage: 'COUNT', nCounted: 50 } }, + ]; + expect(findCountedResult({ executionStages: { stage: 'SHARD_MERGE', shards }, shards })).toBe(80); + }); +}); diff --git a/src/documentdb/queryInsights/analysisProfiles.ts b/src/documentdb/queryInsights/analysisProfiles.ts new file mode 100644 index 000000000..6cb4d77ad --- /dev/null +++ b/src/documentdb/queryInsights/analysisProfiles.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { type Document } from 'mongodb'; +import { type QueryInsightsAnalysisProfile } from '../../webviews/documentdb/collectionView/types/queryInsights'; +import { type ExecutionStatsAnalysis } from './ExplainPlanAnalyzer'; + +export function asExplainObject(value: unknown): Document | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Document) : undefined; +} + +export function getInitialExplainPlan(explain: Document, profile: QueryInsightsAnalysisProfile): Document { + if (profile !== 'aggregate' || !Array.isArray(explain.stages)) return explain; + const first = asExplainObject((explain.stages as unknown[])[0]); + return asExplainObject(first?.$cursor) ?? asExplainObject(first?.$geoNearCursor) ?? explain; +} + +export function getPipelineStageNames(explain: Document): string[] { + if (Array.isArray(explain.stages)) { + return (explain.stages as unknown[]) + .slice(1) + .flatMap((stage) => Object.keys(asExplainObject(stage) ?? {}).filter((key) => key.startsWith('$'))); + } + const pipeline = asExplainObject(explain.command)?.pipeline as unknown; + if (!Array.isArray(pipeline)) return []; + return pipeline.flatMap((stage) => Object.keys(asExplainObject(stage) ?? {}).filter((key) => key.startsWith('$'))); +} + +export function findCountedResult(value: unknown): number | undefined { + const node = asExplainObject(value); + if (!node) return undefined; + if (typeof node.nCounted === 'number' && Number.isFinite(node.nCounted) && node.nCounted >= 0) { + return node.nCounted; + } + const executionCount = findCountedResult(node.executionStages); + if (executionCount !== undefined) return executionCount; + const children: unknown[] = [node.inputStage]; + for (const key of ['inputStages', 'shards']) { + if (Array.isArray(node[key])) children.push(...(node[key] as unknown[])); + } + const counts = children.map(findCountedResult).filter((count): count is number => count !== undefined); + return counts.length > 0 ? counts.reduce((sum, count) => sum + count, 0) : undefined; +} + +export function applyCountRating(analysis: ExecutionStatsAnalysis, totalDocs?: number): void { + const indexed = analysis.usedIndexes.length > 0; + const examinedFraction = totalDocs && totalDocs > 0 ? analysis.totalDocsExamined / totalDocs : undefined; + let score: ExecutionStatsAnalysis['performanceRating']['score']; + if (analysis.totalDocsExamined === 0) score = 'excellent'; + else if (indexed && examinedFraction !== undefined && examinedFraction < 0.1) score = 'excellent'; + else if (indexed && (examinedFraction === undefined || examinedFraction < 0.5)) score = 'good'; + else if (indexed || (examinedFraction !== undefined && examinedFraction < 0.5)) score = 'fair'; + else score = 'poor'; + const skipped = new Set([ + 'no_matching_documents', + 'high_efficiency_ratio', + 'moderate_efficiency_ratio', + 'low_efficiency_ratio', + 'very_low_efficiency_ratio', + 'in_memory_sort', + 'efficient_sorting', + 'no_sorting_required', + ]); + analysis.performanceRating = { + score, + diagnostics: [ + ...analysis.performanceRating.diagnostics.filter((diagnostic) => !skipped.has(diagnostic.diagnosticId)), + { + diagnosticId: 'count_scan_work', + type: score === 'excellent' || score === 'good' ? 'positive' : 'neutral', + message: l10n.t('Count scan work'), + details: + examinedFraction === undefined + ? l10n.t( + 'The count examined {0} documents. Collection size is unavailable; the rating uses index access and examined documents, not returned-document selectivity.', + analysis.totalDocsExamined, + ) + : l10n.t( + 'The count examined {0}% of the collection. The rating uses index access and examined documents, not returned-document selectivity.', + (examinedFraction * 100).toFixed(1), + ), + }, + ], + }; +} diff --git a/src/documentdb/queryInsights/analyzeExplainForStage2.ts b/src/documentdb/queryInsights/analyzeExplainForStage2.ts new file mode 100644 index 000000000..063e9d710 --- /dev/null +++ b/src/documentdb/queryInsights/analyzeExplainForStage2.ts @@ -0,0 +1,171 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { type Document } from 'mongodb'; +import { + type QueryInsightsAnalysisProfile, + type QueryInsightsMetric, + type QueryInsightsStage2Response, +} from '../../webviews/documentdb/collectionView/types/queryInsights'; +import { + applyCountRating, + asExplainObject, + findCountedResult, + getInitialExplainPlan, + getPipelineStageNames, +} from './analysisProfiles'; +import { ExplainPlanAnalyzer, type ExecutionStatsAnalysis } from './ExplainPlanAnalyzer'; +import { StagePropertyExtractor } from './StagePropertyExtractor'; +import { createFailedQueryResponse, transformStage2Response } from './transformations'; + +export function analyzeExplainForStage2( + explain: Document, + totalDocs?: number, + filter?: Document, + profile: QueryInsightsAnalysisProfile = 'find', + existingAnalysis?: ExecutionStatsAnalysis, +): QueryInsightsStage2Response { + const initialPlan = getInitialExplainPlan(explain, profile); + const pipelineStages = profile === 'aggregate' ? getPipelineStageNames(explain) : []; + const winningPlan = asExplainObject(asExplainObject(initialPlan.queryPlanner)?.winningPlan); + const queryPlan = asExplainObject(winningPlan?.queryPlan) ?? winningPlan; + const unscopedAggregate = + profile === 'aggregate' && + initialPlan === explain && + (pipelineStages.some((stage) => stage !== '$match' && stage !== '$sort') || + ['GROUP', 'UNWIND', 'COUNT', 'AGGREGATE'].some((name) => + ExplainPlanAnalyzer.findStageInPlan(queryPlan, name), + )); + const stats = asExplainObject(initialPlan.executionStats); + const counted = profile === 'count' ? findCountedResult(stats) : undefined; + const analysisPlan = + profile === 'count' && stats + ? { ...initialPlan, executionStats: { ...stats, nReturned: counted ?? 0 } } + : initialPlan; + const analyzed = existingAnalysis ?? ExplainPlanAnalyzer.analyzeExecutionStats(analysisPlan, filter); + const executionStages = stats?.executionStages as Document | undefined; + if (executionStages) { + analyzed.extendedStageInfo = StagePropertyExtractor.extractAllExtendedStageInfo(executionStages); + const planner = initialPlan.queryPlanner as Document | undefined; + const winningPlan = planner?.winningPlan as Document | undefined; + if (winningPlan) { + StagePropertyExtractor.enrichWithQueryPlannerInfo(analyzed.extendedStageInfo, winningPlan); + } + } + if (analyzed.executionError) { + const failed = createFailedQueryResponse(analyzed, explain); + return profile === 'find' + ? failed + : { + ...failed, + analysisProfile: profile, + notApplicable: + profile === 'count' + ? [ + 'selectivity', + 'fetchOverhead', + 'inMemorySort', + 'examinedToReturnedRatio', + 'documentsReturned', + ] + : [], + }; + } + if (profile === 'count') applyCountRating(analyzed, totalDocs); + if (unscopedAggregate) analyzed.performanceRating.diagnostics = []; + ExplainPlanAnalyzer.addIndexStrategyAdvisories(analyzed, totalDocs, analysisPlan, filter, { + skipCoverage: profile === 'count' || unscopedAggregate, + }); + const response = transformStage2Response(analyzed, profile === 'count' ? undefined : totalDocs); + if (profile === 'find') return response; + + const notApplicable: QueryInsightsMetric[] = []; + const analysisNotes: string[] = []; + if (profile === 'count') { + notApplicable.push('selectivity', 'fetchOverhead', 'inMemorySort', 'examinedToReturnedRatio'); + if (counted === undefined) notApplicable.push('documentsReturned'); + response.documentsReturned = counted ?? 0; + response.efficiencyAnalysis.selectivity = null; + response.concerns = response.concerns.filter((concern) => !concern.startsWith('High selectivity issue:')); + analysisNotes.push( + l10n.t( + 'Count uses nCounted, not nReturned. Selectivity, returned-document efficiency, coverage and sort rules are not applicable. The rating uses index access and documents examined versus collection size.', + ), + ); + if (counted === undefined) + analysisNotes.push( + l10n.t('This explain does not expose nCounted; the count result is unavailable, not zero.'), + ); + } else { + const stages = pipelineStages; + response.executionTimeScope = unscopedAggregate ? 'optimized-plan' : 'cursor'; + if (typeof stats?.executionTimeMillis !== 'number') { + const lastStage = Array.isArray(explain.stages) + ? asExplainObject((explain.stages as unknown[]).at(-1)) + : undefined; + if (typeof lastStage?.executionTimeMillisEstimate === 'number') { + response.executionTimeMs = lastStage.executionTimeMillisEstimate; + response.executionTimeScope = 'pipeline-estimate'; + } else { + notApplicable.push('executionTime'); + } + notApplicable.push('performanceRating'); + analyzed.performanceRating.diagnostics = analyzed.performanceRating.diagnostics.filter( + (diagnostic) => + !['fast_execution', 'acceptable_execution', 'slow_execution', 'very_slow_execution'].includes( + diagnostic.diagnosticId, + ), + ); + analysisNotes.push( + l10n.t( + 'Initial cursor execution time is unavailable. Any displayed pipeline estimate includes later stages; an initial-stage performance rating is not assessed.', + ), + ); + } + let initialPrefix = true; + const laterStages = Array.isArray(explain.stages) + ? stages + : stages.filter((stage) => { + if (initialPrefix && (stage === '$match' || stage === '$sort')) return false; + initialPrefix = false; + return true; + }); + response.unassessedPipelineStages = laterStages; + const stageNames = laterStages.join(', ') || l10n.t('none reported'); + const note = unscopedAggregate + ? l10n.t( + 'This optimized plan does not expose a separate cursor boundary. Initial-stage returns, selectivity and rating are unavailable; later pipeline stages ({0}) are not rated. Execution time describes the optimized plan.', + stageNames, + ) + : l10n.t( + 'Only the initial cursor stage ($match/$sort) is analyzed; later pipeline stages ({0}) are not rated.', + stageNames, + ); + if (unscopedAggregate) { + notApplicable.push('documentsReturned', 'selectivity', 'examinedToReturnedRatio'); + if (!notApplicable.includes('performanceRating')) notApplicable.push('performanceRating'); + response.efficiencyAnalysis.selectivity = null; + response.concerns = response.concerns.filter((concern) => !concern.startsWith('High selectivity issue:')); + } + analysisNotes.push(note); + response.efficiencyAnalysis.performanceRating.diagnostics.push({ + diagnosticId: 'aggregate_initial_stage_only', + type: 'neutral', + message: l10n.t('Initial pipeline stage only'), + details: note, + }); + if (stages.includes('$sort') && !analyzed.hasInMemorySort) { + notApplicable.push('inMemorySort'); + response.efficiencyAnalysis.performanceRating.diagnostics = + response.efficiencyAnalysis.performanceRating.diagnostics.filter( + (diagnostic) => + diagnostic.diagnosticId !== 'no_sorting_required' && + diagnostic.diagnosticId !== 'efficient_sorting', + ); + } + } + return { ...response, rawExecutionStats: explain, analysisProfile: profile, notApplicable, analysisNotes }; +} diff --git a/src/documentdb/queryInsights/documentDbPlanRefinements.test.ts b/src/documentdb/queryInsights/documentDbPlanRefinements.test.ts new file mode 100644 index 000000000..99b279282 --- /dev/null +++ b/src/documentdb/queryInsights/documentDbPlanRefinements.test.ts @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Document } from 'mongodb'; +import { describe, expect, it } from 'vitest'; +import { fixupDocumentDbExplain } from '../utils/fixupDocumentDbExplain'; +import { analyzeExplainForStage2 } from './analyzeExplainForStage2'; +import { isPrimaryKeyOrderScan } from './documentDbPlanRefinements'; +import { ExplainPlanAnalyzer } from './ExplainPlanAnalyzer'; +import { buildStaticAnalysisSummary } from './staticAnalysisSummary'; +import { transformStage1Response } from './transformations'; + +const filter = { 'address.building': { $gte: 50000 } }; +const runtimeFilterSet = [{ 'address.building': { $gte: 50000 } }, { $unknown: {} }]; + +/** Azure DocumentDB `find` on a non-indexed field: a streaming cursor walks `_id_` (from a real cluster). */ +function primaryKeyWalkPlan(overrides: { indexFilterSet?: Document[]; indexName?: string } = {}): Document { + const indexName = overrides.indexName ?? '_id_'; + const plannerIxscan: Document = { + stage: 'IXSCAN', + cursorScanType: 'streamingPrimaryKey', + indexName, + direction: 'Forward', + indexUsage: {}, + runtimeFilterSet, + estimatedTotalKeysExamined: 10782, + ...(overrides.indexFilterSet ? { indexFilterSet: overrides.indexFilterSet } : {}), + }; + return { + explainVersion: 2, + command: + "db.runCommand({explain: { 'find' : 'restaurants', 'filter' : { 'address.building' : { '$gte' : 50000 } } }})", + queryPlanner: { + namespace: 'Yelp.restaurants', + winningPlan: { + stage: 'FETCH', + cursorScanType: 'streamingPrimaryKey', + runtimeFilterSet, + estimatedTotalKeysExamined: 10782, + inputStage: plannerIxscan, + }, + indexCosts: [ + { + namespace: 'Yelp.restaurants', + costs: [{ indexName, selectivity: 1, boundarySelectivity: 1, estimatedTotalIndexEntries: 64694 }], + }, + ], + }, + executionStats: { + nReturned: 10, + executionTimeMillis: 50.221, + executionStartAtTimeMillis: 4.449, + totalDocsExamined: 64694, + totalKeysExamined: 10, + executionStages: { + stage: 'FETCH', + nReturned: 10, + executionTimeMillis: 50.221, + totalDocsExamined: 64694, + totalKeysExamined: 10, + totalDocsRemovedByRuntimeFilter: 64684, + numBlocksFromCache: 65196, + inputStage: { + stage: 'IXSCAN', + nReturned: 10, + executionTimeMillis: 50.221, + totalDocsExamined: 64694, + indexName, + indexUsage: {}, + totalKeysExamined: 10, + totalDocsRemovedByRuntimeFilter: 64684, + numBlocksFromCache: 65196, + }, + }, + }, + ok: 1, + }; +} + +/** Native MongoDB `find` that genuinely uses `_id_` (no DocumentDB-only fields). */ +function mongoIdScanPlan(): Document { + const ixscan = { + stage: 'IXSCAN', + nReturned: 10, + keysExamined: 10, + indexName: '_id_', + keyPattern: { _id: 1 }, + direction: 'forward', + indexBounds: { _id: ['[MinKey, ObjectId("65000000000000000000000a")]'] }, + }; + return { + queryPlanner: { + namespace: 'Yelp.restaurants', + winningPlan: { stage: 'FETCH', inputStage: { ...ixscan } }, + rejectedPlans: [], + }, + executionStats: { + executionSuccess: true, + nReturned: 10, + executionTimeMillis: 1, + totalKeysExamined: 10, + totalDocsExamined: 10, + executionStages: { stage: 'FETCH', nReturned: 10, docsExamined: 10, inputStage: ixscan }, + }, + serverInfo: { version: '7.0.12' }, + ok: 1, + }; +} + +const vCore = { domainInfo_isAzure: 'true', domainInfo_api: 'vCore' } as const; + +describe('DocumentDB second pass: primary-key order scan', () => { + it.each([ + ['as returned by the server', (): Document => primaryKeyWalkPlan()], + [ + 'after the Azure DocumentDB fixup', + (): Document => { + const plan = primaryKeyWalkPlan(); + fixupDocumentDbExplain(plan, vCore); + return plan; + }, + ], + ])('reports a collection scan, not an index, %s', (_label, makePlan) => { + const plan = makePlan(); + const analyzed = ExplainPlanAnalyzer.analyzeExecutionStats(plan, filter); + + expect(analyzed.planRefinements).toEqual(['primaryKeyOrderScan']); + expect(analyzed).toMatchObject({ + usedIndexes: [], + isCollectionScan: true, + isIndexScan: false, + isCovered: false, + }); + const ids = analyzed.performanceRating.diagnostics.map((d) => d.diagnosticId); + expect(ids).not.toContain('index_used'); + expect(ids).toContain('full_collection_scan'); + expect(ids.indexOf('primary_key_order_scan')).toBe(ids.indexOf('full_collection_scan') + 1); + expect(analyzed.performanceRating.score).toBe('poor'); + + const stage2 = analyzeExplainForStage2(plan, 64694, filter, 'find'); + expect(stage2.efficiencyAnalysis.indexUsed).toBeNull(); + expect(stage2.efficiencyAnalysis.fetchOverheadKind).toBe('collectionScan'); + expect(stage2.executionStrategy).toBe('Collection Scan'); + expect(stage2.hadCollectionScan).toBe(true); + + const summary = buildStaticAnalysisSummary(stage2, 64694); + expect(summary).toContain('- **Index Used**: None (collection scan)'); + expect(summary).toContain('**Collection read in _id order**'); + expect(summary).not.toContain('**Index used**'); + }); + + it('keeps the full collection scan neutral for an unfiltered query', () => { + const analyzed = ExplainPlanAnalyzer.analyzeExecutionStats(primaryKeyWalkPlan(), {}); + const badge = analyzed.performanceRating.diagnostics.find((d) => d.diagnosticId === 'full_collection_scan'); + expect(badge?.type).toBe('neutral'); + }); + + it('applies to the Stage 1 (queryPlanner) summary too', () => { + const plan = primaryKeyWalkPlan(); + delete plan.executionStats; + const stage1 = transformStage1Response(ExplainPlanAnalyzer.analyzeQueryPlanner(plan), 0); + expect(stage1.efficiencyAnalysis.indexUsed).toBeNull(); + expect(stage1.efficiencyAnalysis.executionStrategy).toBe('Collection Scan'); + }); + + it('keeps a real _id lookup (conditions in indexFilterSet) as an index scan', () => { + const plan = primaryKeyWalkPlan({ indexFilterSet: [{ _id: { $gte: 'x' } }] }); + expect(isPrimaryKeyOrderScan(plan)).toBe(false); + const analyzed = ExplainPlanAnalyzer.analyzeExecutionStats(plan, { _id: { $gte: 'x' } }); + expect(analyzed.planRefinements).toEqual([]); + expect(analyzed.usedIndexes).toEqual(['_id_']); + expect(analyzed.performanceRating.diagnostics.map((d) => d.diagnosticId)).toContain('index_used'); + }); + + it('keeps a secondary index on a streaming cursor as an index scan', () => { + const plan = primaryKeyWalkPlan({ indexName: 'address.building_1' }); + expect(isPrimaryKeyOrderScan(plan)).toBe(false); + expect(ExplainPlanAnalyzer.analyzeExecutionStats(plan, filter).usedIndexes).toEqual(['address.building_1']); + }); + + it('leaves native MongoDB plans that use _id_ untouched', () => { + const plan = mongoIdScanPlan(); + expect(isPrimaryKeyOrderScan(plan)).toBe(false); + const analyzed = ExplainPlanAnalyzer.analyzeExecutionStats(plan, { _id: { $lte: 'x' } }); + expect(analyzed.planRefinements).toEqual([]); + expect(analyzed.usedIndexes).toEqual(['_id_']); + expect(analyzed.isIndexScan).toBe(true); + expect(analyzed.performanceRating.diagnostics.map((d) => d.diagnosticId)).toContain('index_used'); + }); +}); diff --git a/src/documentdb/queryInsights/documentDbPlanRefinements.ts b/src/documentdb/queryInsights/documentDbPlanRefinements.ts new file mode 100644 index 000000000..f6bf1101a --- /dev/null +++ b/src/documentdb/queryInsights/documentDbPlanRefinements.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Second analysis pass for DocumentDB explain plans. + * + * The first pass (`@mongodb-js/explain-plan-helper`) reads any MongoDB API explain plan and + * reports which indexes appear in it. Some DocumentDB plans use an index for a purpose that the + * first pass cannot see, so this pass corrects the extracted access facts BEFORE the performance + * rating is computed. Every rule here keys off fields that only DocumentDB emits, so plans from + * other engines pass through unchanged. + */ + +import * as l10n from '@vscode/l10n'; +import { type Document } from 'mongodb'; +import { type PerformanceDiagnostic } from './ExplainPlanAnalyzer'; + +/** How the plan reads documents, as extracted by the first pass. */ +export interface PlanAccessFacts { + usedIndexes: string[]; + isCollectionScan: boolean; + isCovered: boolean; + isIndexScan: boolean; +} + +/** Stable identifiers of the corrections applied, for telemetry and tests. */ +export type PlanRefinementId = 'primaryKeyOrderScan'; + +export interface PlanRefinementResult { + facts: PlanAccessFacts; + refinements: PlanRefinementId[]; + /** Explanatory badges to show next to the rating diagnostics. */ + diagnostics: PerformanceDiagnostic[]; +} + +const PRIMARY_KEY_INDEX = '_id_'; +/** `cursorScanType` of DocumentDB's batched (streaming) cursor that resumes by primary key. */ +const STREAMING_PRIMARY_KEY_CURSOR = 'streamingPrimaryKey'; + +function asDocument(value: unknown): Document | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Document) : undefined; +} + +function collectStages(stage: Document | undefined, into: Document[] = []): Document[] { + if (!stage) return into; + into.push(stage); + collectStages(asDocument(stage.inputStage), into); + if (Array.isArray(stage.inputStages)) { + for (const child of stage.inputStages) collectStages(asDocument(child), into); + } + return into; +} + +function hasConditions(value: unknown): boolean { + return Array.isArray(value) && value.length > 0; +} + +/** + * True when a DocumentDB plan walks the `_id_` index only to page through the collection. + * + * A batched `find` runs as a streaming cursor that resumes from the last `(shard key, _id)` it + * returned, so DocumentDB turns a sequential scan into a walk of the `_id_` index + * (`cursorScanType: "streamingPrimaryKey"`). The explain then shows `IXSCAN` on `_id_`, but no + * query condition is applied in the index (`indexFilterSet` is absent) and every document is read + * and filtered afterwards (`runtimeFilterSet`): a collection scan. A real `_id` lookup or range + * keeps its conditions in `indexFilterSet` and is not matched. + */ +export function isPrimaryKeyOrderScan(explain: Document): boolean { + const winningPlan = asDocument(asDocument(explain.queryPlanner)?.winningPlan); + const stages = collectStages(winningPlan); + if (!stages.some((stage) => stage.cursorScanType === STREAMING_PRIMARY_KEY_CURSOR)) { + return false; + } + const indexScans = stages.filter((stage) => typeof stage.indexName === 'string' || stage.stage === 'IXSCAN'); + return ( + indexScans.length > 0 && + indexScans.every((stage) => stage.indexName === PRIMARY_KEY_INDEX && !hasConditions(stage.indexFilterSet)) + ); +} + +/** + * Applies the DocumentDB corrections to the facts extracted by the first pass. + * Returns the facts unchanged (and no refinements) for every other plan. + */ +export function refineDocumentDbPlanFacts(explain: Document, facts: PlanAccessFacts): PlanRefinementResult { + const onlyPrimaryKey = + facts.usedIndexes.length > 0 && facts.usedIndexes.every((name) => name === PRIMARY_KEY_INDEX); + if (!onlyPrimaryKey || !isPrimaryKeyOrderScan(explain)) { + return { facts, refinements: [], diagnostics: [] }; + } + return { + facts: { usedIndexes: [], isCollectionScan: true, isCovered: false, isIndexScan: false }, + refinements: ['primaryKeyOrderScan'], + diagnostics: [ + { + diagnosticId: 'primary_key_order_scan', + type: 'neutral', + message: l10n.t('Collection read in _id order'), + details: l10n.t( + 'The plan lists an index scan on _id_, but no query condition is applied in that index. DocumentDB reads the documents in _id order so the results can be returned in batches, and checks every document against the filter afterwards. This is a full collection scan.\n\nKeys Examined counts only the documents that passed the filter, not every index entry that was read.', + ), + }, + ], + }; +} diff --git a/src/documentdb/queryInsights/query-insights-static-analysis.md b/src/documentdb/queryInsights/query-insights-static-analysis.md index 2f1f41a7c..0d50454cf 100644 --- a/src/documentdb/queryInsights/query-insights-static-analysis.md +++ b/src/documentdb/queryInsights/query-insights-static-analysis.md @@ -40,6 +40,8 @@ revert without understanding the context. v ExplainPlanAnalyzer.analyzeExecutionStats(explainResult, queryFilter) | + |--- explain-plan-helper --> first pass: engine-neutral access facts + |--- refineDocumentDbPlanFacts() --> second pass: DocumentDB-only corrections |--- calculatePerformanceRating() --> score + 4 diagnostic badges | v @@ -185,12 +187,15 @@ These always appear (one per dimension): #### Index Usage -| diagnosticId | Condition | Type | Message | -| ---------------------- | ------------------------------- | -------- | -------------------- | -| `index_used` | Index scan used | positive | Index used | -| `full_collection_scan` | Collection scan, no filter | neutral | Full collection scan | -| `full_collection_scan` | Collection scan, filter present | negative | Full collection scan | -| `no_index_used` | No index, no collection scan | neutral | No index used | +| diagnosticId | Condition | Type | Message | +| ------------------------ | ---------------------------------------------------------------------------------------------- | -------- | ----------------------------- | +| `index_used` | Index scan used | positive | Index used | +| `full_collection_scan` | Collection scan, no filter | neutral | Full collection scan | +| `full_collection_scan` | Collection scan, filter present | negative | Full collection scan | +| `no_index_used` | No index, no collection scan | neutral | No index used | +| `primary_key_order_scan` | DocumentDB walked `_id_` only to page (see [Decision 6](#6-primary-key-order-scan-documentdb)) | neutral | Collection read in \_id order | + +`primary_key_order_scan` is added by the second pass, right after `full_collection_scan`. **Empty query detection:** The `queryFilter` parameter is passed directly from the user's parsed filter (via `ClusterSession`), not extracted from the @@ -464,6 +469,35 @@ The with-filter message is `negative` type (orange badge) while the no-filter message is `neutral` (blue/gray) because scanning everything when you asked for everything is expected behavior. +### 6. Primary-Key Order Scan (DocumentDB) + +**Problem:** A DocumentDB `find` on a field without an index was shown as +**Index Used: `_id_`** with a positive "Index used" badge. A batched `find` +runs as a streaming cursor that resumes from the last `(shard key, _id)` it +returned, so DocumentDB turns the sequential scan into a walk of the `_id_` +index. The explain reports `IXSCAN` on `_id_`, but: + +- `queryPlanner.winningPlan` has `cursorScanType: "streamingPrimaryKey"`; +- the `_id_` stage has no `indexFilterSet`: every condition is in + `runtimeFilterSet` and is checked after reading the document; +- `totalDocsExamined` equals the collection size, while `totalKeysExamined` + counts only the documents that passed the filter. + +**Decision:** a second analysis pass (`documentDbPlanRefinements.ts`) runs +after the engine-neutral extraction and **before** the rating. When all of +the above hold and `_id_` is the only index in the plan, it reports a +collection scan (no index, `Collection scan` fetch overhead) and adds the +neutral `primary_key_order_scan` badge. The rules key off fields only +DocumentDB emits (`cursorScanType`, `indexFilterSet`), so MongoDB plans are +unchanged. A real `_id` lookup keeps its condition in `indexFilterSet` and is +still reported as an index scan. The applied corrections are recorded in +`ExecutionStatsAnalysis.planRefinements` and the Stage 2 telemetry property +`planRefinements`. + +Source: `cursorScanType` in DocumentDB's explain output schema +(`pg_documentdb_gw/.../explain/documentdb_explain_output.schema.json`) and the +cursor continuation planner (`pg_documentdb/src/customscan/custom_scan_continuation.c`). + --- ## Configuration Constants diff --git a/src/documentdb/queryInsights/staticAnalysisSummary.test.ts b/src/documentdb/queryInsights/staticAnalysisSummary.test.ts index 43fee1861..da51d9d5f 100644 --- a/src/documentdb/queryInsights/staticAnalysisSummary.test.ts +++ b/src/documentdb/queryInsights/staticAnalysisSummary.test.ts @@ -60,6 +60,53 @@ function makeStage2Response(overrides: Partial = {} } describe('buildStaticAnalysisSummary', () => { + it('explains skipped count rules instead of presenting find selectivity or returned-document claims', () => { + const summary = buildStaticAnalysisSummary( + makeStage2Response({ + analysisProfile: 'count', + documentsReturned: 123, + notApplicable: ['selectivity', 'fetchOverhead', 'inMemorySort', 'examinedToReturnedRatio'], + }), + 1000, + ); + expect(summary).toContain('Analysis Profile: COUNT'); + expect(summary).toContain('Documents counted (nCounted): 123'); + expect(summary).toContain('Skipped rules: returned-document efficiency ratio'); + expect(summary).toContain('does NOT mean zero matches'); + expect(summary).not.toContain('Documents returned by query'); + expect(summary).not.toContain('**Selectivity**'); + expect(summary).not.toContain('**Fetch Overhead**'); + expect(summary).not.toContain('**In-Memory Sort**'); + }); + + it('does not tell the model a missing nCounted means zero', () => { + const summary = buildStaticAnalysisSummary( + makeStage2Response({ + analysisProfile: 'count', + documentsReturned: 0, + notApplicable: ['documentsReturned'], + }), + ); + expect(summary).toContain('Documents counted (nCounted): Unavailable'); + expect(summary).not.toContain('Documents counted (nCounted): 0'); + }); + + it('scopes aggregate metrics to the cursor and explains unrated stages and pipeline sorting', () => { + const summary = buildStaticAnalysisSummary( + makeStage2Response({ + analysisProfile: 'aggregate', + notApplicable: ['inMemorySort'], + unassessedPipelineStages: ['$group', '$sort'], + }), + ); + expect(summary).toContain('Documents passed to pipeline (initial cursor): 50'); + expect(summary).toContain('Later pipeline stages not rated: $group, $sort'); + expect(summary).toContain('NOT final pipeline output'); + expect(summary).toContain('**Initial-stage selectivity**'); + expect(summary).toContain('**In-Memory Sort**: Not rated for pipeline $sort'); + expect(summary.split('\n')).not.toContain('- **In-Memory Sort**: No'); + }); + it('should include the header explaining context to the LLM', () => { const summary = buildStaticAnalysisSummary(makeStage2Response()); expect(summary).toContain('## Static Analysis Results'); diff --git a/src/documentdb/queryInsights/staticAnalysisSummary.ts b/src/documentdb/queryInsights/staticAnalysisSummary.ts index b5e5b0ff0..4f4fb29ee 100644 --- a/src/documentdb/queryInsights/staticAnalysisSummary.ts +++ b/src/documentdb/queryInsights/staticAnalysisSummary.ts @@ -19,6 +19,8 @@ import { type QueryInsightsStage2Response } from '../../webviews/documentdb/coll */ export function buildStaticAnalysisSummary(stage2: QueryInsightsStage2Response, totalCollectionDocs?: number): string { const lines: string[] = []; + const profile = stage2.analysisProfile ?? 'find'; + const notApplicable = new Set(stage2.notApplicable ?? []); lines.push('## Static Analysis Results (already shown to user)'); lines.push(''); @@ -26,34 +28,105 @@ export function buildStaticAnalysisSummary(stage2: QueryInsightsStage2Response, lines.push('Your analysis should build on these results, not contradict them without explanation.'); lines.push(''); + if (profile !== 'find') { + lines.push(`### Analysis Profile: ${profile.toUpperCase()} (preview, tuned for find queries)`); + if (profile === 'count') { + lines.push( + 'Count results come from nCounted, not nReturned. A count returning zero documents does NOT mean zero matches.', + ); + lines.push( + 'Skipped rules: returned-document efficiency ratio, collection-return selectivity/coverage, fetch-overhead assessment, and sort assessment. These find rules do not apply to count commands.', + ); + lines.push( + 'The count rating uses index access and examined documents versus collection size. Do not reinstate skipped find diagnostics or infer missing nCounted as zero.', + ); + } else { + lines.push( + notApplicable.has('documentsReturned') + ? 'This optimized single-plan explain does not expose a separate cursor boundary. Initial-stage returns, selectivity and rating are unavailable; do not substitute final pipeline output.' + : 'Only the initial cursor / pushed-down $match and $sort stage is analyzed. Documents passed to pipeline and selectivity refer to that stage, NOT final pipeline output.', + ); + lines.push( + `Later pipeline stages not rated: ${(stage2.unassessedPipelineStages ?? []).join(', ') || 'none reported'}.`, + ); + lines.push('Do not treat the initial-stage score as a rating of the complete aggregation pipeline.'); + if (notApplicable.has('inMemorySort')) { + lines.push( + 'Skipped rule: no-in-memory-sort / efficient-sorting conclusion. A pipeline $sort exists, but the cursor plan cannot determine its memory or disk usage.', + ); + } + } + for (const note of stage2.analysisNotes ?? []) lines.push(`- ${note}`); + lines.push(''); + } + // Collection context lines.push('### Collection Context'); if (totalCollectionDocs !== undefined && totalCollectionDocs > 0) { lines.push(`- Total documents in collection: ${totalCollectionDocs.toLocaleString()}`); } - lines.push(`- Documents returned by query: ${stage2.documentsReturned.toLocaleString()}`); + if (profile === 'count') { + lines.push( + notApplicable.has('documentsReturned') + ? '- Documents counted (nCounted): Unavailable in this explain' + : `- Documents counted (nCounted): ${stage2.documentsReturned.toLocaleString()}`, + ); + } else if (profile === 'aggregate') { + lines.push( + `- Documents passed to pipeline (initial cursor): ${notApplicable.has('documentsReturned') ? 'Unavailable in this explain' : stage2.documentsReturned.toLocaleString()}`, + ); + } else { + lines.push(`- Documents returned by query: ${stage2.documentsReturned.toLocaleString()}`); + } lines.push(`- Documents examined: ${stage2.totalDocsExamined.toLocaleString()}`); lines.push(`- Keys examined: ${stage2.totalKeysExamined.toLocaleString()}`); - lines.push(`- Execution time: ${stage2.executionTimeMs}ms`); + let timeLabel = 'Execution time'; + if (profile === 'aggregate') { + timeLabel = 'Initial cursor execution time'; + if (stage2.executionTimeScope === 'optimized-plan') timeLabel = 'Optimized plan execution time'; + if (stage2.executionTimeScope === 'pipeline-estimate') + timeLabel = 'Overall pipeline execution time (estimate, not cursor-only)'; + } + lines.push(`- ${timeLabel}: ${notApplicable.has('executionTime') ? 'Unavailable' : `${stage2.executionTimeMs}ms`}`); lines.push(''); // Performance rating const rating = stage2.efficiencyAnalysis.performanceRating; - lines.push(`### Performance Rating: ${rating.score.toUpperCase()} (scale: Excellent > Good > Fair > Poor)`); + lines.push( + notApplicable.has('performanceRating') + ? '### Performance Rating: Not assessed for this profile' + : `### Performance Rating: ${rating.score.toUpperCase()} (scale: Excellent > Good > Fair > Poor)`, + ); lines.push(''); // Summary indicators - lines.push('### Summary Indicators (4 cells shown to user)'); + lines.push( + profile === 'find' + ? '### Summary Indicators (4 cells shown to user)' + : '### Summary Indicators (applicable cells only)', + ); const formattedSelectivity = stage2.efficiencyAnalysis.selectivity !== null ? formatSelectivityForSummary(stage2.efficiencyAnalysis.selectivity) : 'Unknown'; - lines.push(`- **Selectivity**: ${formattedSelectivity}`); - lines.push(`- **Index Used**: ${stage2.efficiencyAnalysis.indexUsed ?? 'None (collection scan)'}`); + if (profile !== 'count' && !notApplicable.has('selectivity')) { + lines.push( + `- **${profile === 'aggregate' ? 'Initial-stage selectivity' : 'Selectivity'}**: ${formattedSelectivity}`, + ); + } lines.push( - `- **Fetch Overhead**: ${stage2.efficiencyAnalysis.fetchOverhead} (${stage2.efficiencyAnalysis.fetchOverheadKind})`, + `- **Index Used**: ${stage2.efficiencyAnalysis.indexUsed ?? (profile === 'find' ? 'None (collection scan)' : 'None reported')}`, ); - lines.push(`- **In-Memory Sort**: ${stage2.efficiencyAnalysis.hasInMemorySort ? 'Yes' : 'No'}`); + if (profile !== 'count' && !notApplicable.has('fetchOverhead')) { + lines.push( + `- **Fetch Overhead**: ${stage2.efficiencyAnalysis.fetchOverhead} (${stage2.efficiencyAnalysis.fetchOverheadKind})`, + ); + } + if (profile !== 'count') { + let sortDescription = stage2.efficiencyAnalysis.hasInMemorySort ? 'Yes' : 'No'; + if (notApplicable.has('inMemorySort')) sortDescription = 'Not rated for pipeline $sort'; + lines.push(`- **In-Memory Sort**: ${sortDescription}`); + } lines.push(''); // Diagnostic badges diff --git a/src/services/ai/QueryInsightsAIService.streaming.test.ts b/src/services/ai/QueryInsightsAIService.streaming.test.ts index 44e7d5764..3750716af 100644 --- a/src/services/ai/QueryInsightsAIService.streaming.test.ts +++ b/src/services/ai/QueryInsightsAIService.streaming.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { CommandType } from '../../commands/llmEnhancedCommands/indexAdvisorCommands'; // Mock the telemetry wrapper so the SUT's callWithTelemetryAndErrorHandling // callback runs directly. We re-throw on errors so the caller's error path is @@ -166,4 +167,29 @@ describe('QueryInsightsAIService.getOptimizationRecommendationsStreaming (WI-3)' // can render a clean error state. await expect(handle.completion).rejects.toThrow(/Failed to parse AI optimization response/); }); + + it('forwards connectionless context and a non-find command without changing connected defaults', async () => { + optimizeQueryStreamingMock.mockResolvedValueOnce(makeStubHandle([completeJsonResponse])); + const handle = await service.getOptimizationRecommendationsStreaming( + '', + '[]', + 'db', + 'items', + {}, + undefined, + undefined, + { + providedData: { clusterInfo: { platform: 'documentdb' } }, + modelId: 'picked', + commandType: CommandType.Aggregate, + }, + ); + await handle.completion; + expect(optimizeQueryStreamingMock.mock.calls[0][1]).toMatchObject({ + query: '[]', + commandType: 'aggregate', + modelId: 'picked', + providedData: { clusterInfo: { platform: 'documentdb' } }, + }); + }); }); diff --git a/src/services/ai/QueryInsightsAIService.ts b/src/services/ai/QueryInsightsAIService.ts index dbd19776d..c860556db 100644 --- a/src/services/ai/QueryInsightsAIService.ts +++ b/src/services/ai/QueryInsightsAIService.ts @@ -142,6 +142,7 @@ export class QueryInsightsAIService { parsedResponse.modelFamily = optimizationResult.modelFamily; parsedResponse.modelDisplayName = optimizationResult.modelDisplayName; parsedResponse.usage = optimizationResult.usage; + parsedResponse.modelInputs = optimizationResult.modelInputs; // Count all actionable recommendations by action type in a single pass let actionableRecommendationCount = 0; @@ -206,6 +207,10 @@ export class QueryInsightsAIService { executionPlan?: unknown, signal?: AbortSignal, staticAnalysisSummary?: string, + options?: Pick< + QueryOptimizationContext, + 'providedData' | 'modelId' | 'collectionStats' | 'indexStats' | 'commandType' + >, ): Promise { const result = await callWithTelemetryAndErrorHandling( 'vscode-documentdb.queryInsights.getOptimizationRecommendationsStreaming', @@ -235,6 +240,7 @@ export class QueryInsightsAIService { }; } + queryContext = { ...queryContext, ...options }; const streamHandle = await optimizeQueryStreaming(context, queryContext); // Final parse + per-action counts happen once at completion time. @@ -250,6 +256,7 @@ export class QueryInsightsAIService { parsedResponse.modelFamily = optimizationResult.modelFamily; parsedResponse.modelDisplayName = optimizationResult.modelDisplayName; parsedResponse.usage = optimizationResult.usage; + parsedResponse.modelInputs = optimizationResult.modelInputs; return parsedResponse; }, ); diff --git a/src/services/ai/modelSelection.test.ts b/src/services/ai/modelSelection.test.ts new file mode 100644 index 000000000..179ad664a --- /dev/null +++ b/src/services/ai/modelSelection.test.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as vscode from 'vscode'; +import { + getAvailableModels, + QUERY_INSIGHTS_SELECTED_MODEL_KEY, + resolveQueryInsightsModel, + setSavedModelId, +} from './modelSelection'; + +const mocks = vi.hoisted(() => ({ + selectChatModels: vi.fn(), + get: vi.fn(), + update: vi.fn(), +})); + +vi.mock('vscode', () => ({ + lm: { selectChatModels: mocks.selectChatModels }, + l10n: { t: (message: string): string => message }, +})); +vi.mock('../../extensionVariables', () => ({ + ext: { + context: { globalState: { get: mocks.get, update: mocks.update } }, + outputChannel: { warn: vi.fn() }, + }, +})); + +function model(id: string, family = id): vscode.LanguageModelChat { + return { + id, + family, + vendor: 'copilot', + name: id, + version: '1', + maxInputTokens: 1000, + countTokens: vi.fn(), + sendRequest: vi.fn(), + }; +} + +describe('Query Insights model selection', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.selectChatModels.mockResolvedValue([]); + }); + + it('omits auto, deduplicates ids and adds the utility alias without changing list order', async () => { + mocks.selectChatModels + .mockResolvedValueOnce([model('auto'), model('first'), model('first'), model('second')]) + .mockResolvedValueOnce([model('copilot-utility')]); + + const result = await getAvailableModels(); + + expect(result.models.map((entry) => entry.id)).toEqual(['first', 'second', 'copilot-utility']); + expect(result.effectiveModelId).toBe('first'); + expect(result.models[2].isUtility).toBe(true); + expect(mocks.selectChatModels).toHaveBeenLastCalledWith({ vendor: 'copilot', family: 'copilot-utility' }); + }); + + it('resolves explicit, then saved, then first available, including stale preferences', async () => { + mocks.get.mockReturnValue('saved'); + mocks.selectChatModels.mockResolvedValue([model('first'), model('saved'), model('copilot-utility')]); + + expect((await resolveQueryInsightsModel('copilot-utility'))?.source).toBe('explicit'); + expect((await resolveQueryInsightsModel('missing'))?.model.id).toBe('saved'); + expect((await getAvailableModels()).effectiveModelId).toBe('saved'); + + mocks.get.mockReturnValue('removed'); + expect((await resolveQueryInsightsModel())?.source).toBe('first-available'); + expect((await getAvailableModels()).effectiveModelId).toBe('first'); + }); + + it('retains Fast variants by id and preserves the provider name even when display names coincide', async () => { + mocks.selectChatModels.mockResolvedValue([ + { ...model('gpt-5.6-sol'), name: 'GPT-5.6 Sol' }, + { ...model('gpt-5.6-sol-fast'), name: 'GPT-5.6 Sol' }, + model('copilot-utility'), + ]); + const result = await getAvailableModels(); + expect(result.models.filter((entry) => !entry.isUtility).map(({ id, name }) => ({ id, name }))).toEqual([ + { id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol' }, + { id: 'gpt-5.6-sol-fast', name: 'GPT-5.6 Sol' }, + ]); + }); + + it('defaults to the first Powerful model (API category) when nothing valid is saved', async () => { + mocks.get.mockReturnValue(null); + const powerful = (id: string): vscode.LanguageModelChat => + Object.freeze({ ...model(id), category: 'powerful' }); + const versatile = Object.freeze({ ...model('versatile'), category: 'versatile' }); + mocks.selectChatModels.mockResolvedValue([ + model('copilot-utility'), + versatile, + powerful('strong'), + powerful('stronger'), + ]); + + const result = await getAvailableModels(); + expect(result.effectiveModelId).toBe('strong'); + expect(result.effectiveReason).toBe('first-powerful'); + expect((await resolveQueryInsightsModel())?.source).toBe('first-powerful'); + + mocks.get.mockReturnValue('versatile'); + expect((await getAvailableModels()).effectiveReason).toBe('saved'); + }); + + it('returns an empty result when model discovery fails or no models exist', async () => { + expect(await resolveQueryInsightsModel()).toBeUndefined(); + mocks.selectChatModels.mockRejectedValueOnce(new Error('unavailable')); + expect(await getAvailableModels()).toEqual({ + models: [], + savedModelId: null, + effectiveModelId: null, + effectiveReason: null, + }); + }); + + it('persists and clears the shared preference', async () => { + await setSavedModelId('selected'); + await setSavedModelId(null); + expect(mocks.update.mock.calls).toEqual([ + [QUERY_INSIGHTS_SELECTED_MODEL_KEY, 'selected'], + [QUERY_INSIGHTS_SELECTED_MODEL_KEY, undefined], + ]); + }); + + it('defensively reads pricing and capabilities from frozen runtime models', async () => { + const priced = Object.freeze({ + ...model('priced'), + pricing: '0.33x', + priceCategory: 'low', + category: 'versatile', + inputCost: 0, + outputCost: 2, + cacheCost: 0.1, + cacheWriteCost: 0.2, + longContextInputCost: 4, + longContextOutputCost: 8, + longContextCacheCost: 0.5, + longContextCacheWriteCost: 0.6, + capabilities: { supportsToolCalling: true, supportsImageToText: false }, + }); + mocks.selectChatModels.mockResolvedValue([priced, model('copilot-utility')]); + expect((await getAvailableModels()).models[0]).toMatchObject({ + pricing: '0.33x', + priceCategory: 'low', + category: 'versatile', + version: '1', + inputCost: 0, + outputCost: 2, + cacheCost: 0.1, + cacheWriteCost: 0.2, + longContextInputCost: 4, + longContextOutputCost: 8, + longContextCacheCost: 0.5, + longContextCacheWriteCost: 0.6, + supportsToolCalling: true, + supportsImageToText: false, + }); + }); + + it('omits malformed optional metadata without discarding the model', async () => { + mocks.selectChatModels.mockResolvedValue([ + { + ...model('bad'), + pricing: 3, + category: null, + inputCost: '1', + outputCost: NaN, + cacheCost: -1, + longContextInputCost: Infinity, + capabilities: { supportsToolCalling: 'yes', supportsImageToText: 1 }, + }, + model('copilot-utility'), + ]); + expect((await getAvailableModels()).models[0]).toEqual({ + id: 'bad', + name: 'bad', + family: 'bad', + vendor: 'copilot', + version: '1', + maxInputTokens: 1000, + isUtility: false, + }); + }); +}); diff --git a/src/services/ai/modelSelection.ts b/src/services/ai/modelSelection.ts new file mode 100644 index 000000000..e8cf37fae --- /dev/null +++ b/src/services/ai/modelSelection.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * POC: user-selectable language model for Query Insights. + * + * Mirrors the approach used by vscode-cosmosdb (`src/utils/aiUtils.ts`): + * list Copilot models via `vscode.lm.selectChatModels`, drop the virtual + * `auto` model (it does not support `sendRequest` / `countTokens`), and keep + * the user's choice in `globalState`. + * + * Resolution order: explicit model id → saved model id → first model in the list. + * + * The organization / account default chat model is not exposed on the + * stable extension API (VS Code only hands it to chat participants as + * `request.model`), so "first in the list" is the best available fallback. + */ + +import * as vscode from 'vscode'; +import { ext } from '../../extensionVariables'; + +/** Shared by the Collection View Query Insights tab and the standalone wizard. */ +export const QUERY_INSIGHTS_SELECTED_MODEL_KEY = 'documentDB.queryInsights.selectedModelId'; + +export const UTILITY_MODEL_ID = 'copilot-utility'; + +export interface AvailableModelInfo { + id: string; + name: string; + family: string; + vendor: string; + version: string; + maxInputTokens: number; + isUtility: boolean; + pricing?: string; + priceCategory?: string; + category?: string; + inputCost?: number; + outputCost?: number; + cacheCost?: number; + cacheWriteCost?: number; + longContextInputCost?: number; + longContextOutputCost?: number; + longContextCacheCost?: number; + longContextCacheWriteCost?: number; + supportsToolCalling?: boolean; + supportsImageToText?: boolean; +} + +export interface AvailableModelsResult { + models: AvailableModelInfo[]; + /** The saved preference, or `null` when nothing was picked yet. */ + savedModelId: string | null; + /** The model that would be used right now (saved, else default). */ + effectiveModelId: string | null; + /** Why {@link effectiveModelId} was chosen. */ + effectiveReason: ModelSelectionReason | null; +} + +/** + * `saved`: the user's choice. `first-powerful`: no (available) saved choice, so the first model the + * Copilot API puts in its `powerful` category (F7). `first-available`: no category data at all. + */ +export type ModelSelectionReason = 'saved' | 'first-powerful' | 'first-available'; + +function pickDefaultModel( + models: readonly vscode.LanguageModelChat[], +): { model: vscode.LanguageModelChat; reason: Exclude } | undefined { + const powerful = models.find((m) => !isUtilityModel(m) && readRuntimeModelMetadata(m).category === 'powerful'); + if (powerful) { + return { model: powerful, reason: 'first-powerful' }; + } + return models[0] ? { model: models[0], reason: 'first-available' } : undefined; +} + +function pickEffectiveModel( + models: readonly vscode.LanguageModelChat[], + savedModelId: string | null, +): { model: vscode.LanguageModelChat; reason: ModelSelectionReason } | undefined { + const saved = savedModelId ? models.find((m) => m.id === savedModelId) : undefined; + return saved ? { model: saved, reason: 'saved' } : pickDefaultModel(models); +} + +function isAutoModel(model: vscode.LanguageModelChat): boolean { + return model.id === 'auto' || model.name?.toLowerCase() === 'auto'; +} + +function isUtilityModel(model: vscode.LanguageModelChat): boolean { + return model.id === UTILITY_MODEL_ID || model.family === UTILITY_MODEL_ID; +} + +async function listCopilotModels(): Promise { + const all = await vscode.lm.selectChatModels({ vendor: 'copilot' }); + const models = all.filter((m) => !isAutoModel(m)); + + // The utility alias is not guaranteed to be part of the unfiltered list; keep it selectable. + if (!models.some(isUtilityModel)) { + const utility = await vscode.lm.selectChatModels({ vendor: 'copilot', family: UTILITY_MODEL_ID }); + models.push(...utility); + } + + // Some providers publish the same id more than once (e.g. per location); keep the first. + const seen = new Set(); + return models.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true))); +} + +function toInfo(model: vscode.LanguageModelChat): AvailableModelInfo { + return { + ...readRuntimeModelMetadata(model), + id: model.id, + name: isUtilityModel(model) ? vscode.l10n.t('{0} (utility)', model.name || model.id) : model.name || model.id, + family: model.family, + vendor: model.vendor, + version: model.version, + maxInputTokens: model.maxInputTokens, + isUtility: isUtilityModel(model), + }; +} + +/** POC only: these runtime properties are typed by the proposed pricing API (F7). */ +function readRuntimeModelMetadata(value: unknown): Partial { + if (typeof value !== 'object' || value === null) return {}; + const model = value as Record; + const metadata: Partial = {}; + for (const key of ['pricing', 'priceCategory', 'category'] as const) { + if (typeof model[key] === 'string') metadata[key] = model[key]; + } + for (const key of [ + 'inputCost', + 'outputCost', + 'cacheCost', + 'cacheWriteCost', + 'longContextInputCost', + 'longContextOutputCost', + 'longContextCacheCost', + 'longContextCacheWriteCost', + ] as const) { + const cost = model[key]; + if (typeof cost === 'number' && Number.isFinite(cost) && cost >= 0) metadata[key] = cost; + } + const capabilities: unknown = model.capabilities; + if (typeof capabilities === 'object' && capabilities !== null) { + const record = capabilities as Record; + for (const key of ['supportsToolCalling', 'supportsImageToText'] as const) { + if (typeof record[key] === 'boolean') metadata[key] = record[key]; + } + } + return metadata; +} + +export function getSavedModelId(): string | null { + return ext.context.globalState.get(QUERY_INSIGHTS_SELECTED_MODEL_KEY) ?? null; +} + +export async function setSavedModelId(modelId: string | null): Promise { + await ext.context.globalState.update(QUERY_INSIGHTS_SELECTED_MODEL_KEY, modelId ?? undefined); +} + +export async function getAvailableModels(): Promise { + const savedModelId = getSavedModelId(); + try { + const models = await listCopilotModels(); + const effective = pickEffectiveModel(models, savedModelId); + return { + models: models.map(toInfo), + savedModelId, + effectiveModelId: effective?.model.id ?? null, + effectiveReason: effective?.reason ?? null, + }; + } catch (error) { + ext.outputChannel.warn( + vscode.l10n.t( + '[Query Insights] Failed to list language models: {0}', + error instanceof Error ? error.message : String(error), + ), + ); + return { models: [], savedModelId, effectiveModelId: null, effectiveReason: null }; + } +} + +/** + * Resolves the model Query Insights should use. + * @param explicitModelId takes precedence over the saved preference when available. + */ +export async function resolveQueryInsightsModel( + explicitModelId?: string, +): Promise<{ model: vscode.LanguageModelChat; source: 'explicit' | ModelSelectionReason } | undefined> { + const models = await listCopilotModels(); + if (models.length === 0) { + return undefined; + } + + if (explicitModelId) { + const explicit = models.find((m) => m.id === explicitModelId); + if (explicit) { + return { model: explicit, source: 'explicit' }; + } + } + + const effective = pickEffectiveModel(models, getSavedModelId()); + return effective ? { model: effective.model, source: effective.reason } : undefined; +} diff --git a/src/services/ai/queryInsightsModelsRouter.test.ts b/src/services/ai/queryInsightsModelsRouter.test.ts new file mode 100644 index 000000000..4ae614cb7 --- /dev/null +++ b/src/services/ai/queryInsightsModelsRouter.test.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { queryInsightsModelsRouter } from '../../webviews/documentdb/queryInsightsShared/queryInsightsModelsRouter'; + +const mocks = vi.hoisted(() => ({ + getAvailableModels: vi.fn(), + setSavedModelId: vi.fn(), +})); +vi.mock('./modelSelection', () => ({ + getAvailableModels: mocks.getAvailableModels, + setSavedModelId: mocks.setSavedModelId, +})); +vi.mock('../../webviews/_integration/trpc', async () => { + const { initTRPC } = await import('@trpc/server'); + const trpc = initTRPC.create(); + return { router: trpc.router, publicProcedureWithTelemetry: trpc.procedure }; +}); + +describe('Shared Query Insights model RPCs', () => { + const result = { models: [], savedModelId: null, effectiveModelId: null, effectiveReason: null }; + const caller = queryInsightsModelsRouter.createCaller({}); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.getAvailableModels.mockResolvedValue(result); + }); + + it('exposes discovery without requiring a connection context', async () => { + expect(await caller.getAvailableModels()).toEqual(result); + }); + + it('persists or clears a choice and returns the effective selection', async () => { + expect(await caller.setSelectedModel({ modelId: 'picked' })).toEqual(result); + await caller.setSelectedModel({ modelId: null }); + expect(mocks.setSavedModelId.mock.calls).toEqual([['picked'], [null]]); + }); + + it('rejects empty model ids', async () => { + await expect(caller.setSelectedModel({ modelId: '' })).rejects.toThrow(); + expect(mocks.setSavedModelId).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/ai/types.ts b/src/services/ai/types.ts index 195dc2c77..2221760d3 100644 --- a/src/services/ai/types.ts +++ b/src/services/ai/types.ts @@ -52,6 +52,21 @@ export interface AIOptimizationResponse { totalTokens?: number; maxInputTokens?: number; promptUtilizationPct?: number; + reportedPromptTokens?: number; + reportedCompletionTokens?: number; + cachedPromptTokens?: number; + reasoningTokens?: number; + credits?: number; + usageSource?: 'provider' | 'estimated'; + }; + /** + * The data messages sent with the request (user query + context data) and the + * prompt source. Never the instruction prompt itself. Used by "copy as Markdown". + */ + modelInputs?: { + userQuery: string; + contextData: string; + promptSource: string; }; } @@ -60,6 +75,11 @@ export interface AIOptimizationResponse { */ export interface AIIndexRecommendation { action: 'create' | 'drop' | 'none' | 'modify'; + /** + * For `action: "none"` only: what kind of non-index advice this is. Optional; older prompts + * prefix `justification` with the kind instead (see `createInsightCardConfig`). + */ + kind?: string; indexSpec: Record; // e.g., { user_id: 1, status: 1 } indexOptions?: Record; indexName: string; // Name of the index diff --git a/src/services/copilotService.test.ts b/src/services/copilotService.test.ts index 4719fe2e1..3526a1478 100644 --- a/src/services/copilotService.test.ts +++ b/src/services/copilotService.test.ts @@ -6,6 +6,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type IActionContext, UserCancelledError } from '@microsoft/vscode-azext-utils'; +import { LanguageModelDataPart, LanguageModelTextPart } from 'vscode'; +const telemetry = vi.hoisted((): { event: string; measurements: IActionContext['telemetry']['measurements'] }[] => []); // Mock vscode-azext-utils — the real `callWithTelemetryAndErrorHandling` // returns `undefined` for unhandled errors; we simulate the success path and @@ -20,13 +22,14 @@ vi.mock('@microsoft/vscode-azext-utils', () => { return { UserCancelledError: MockUserCancelledError, callWithTelemetryAndErrorHandling: vi.fn( - async (_callbackId: string, callback: (context: IActionContext) => Promise): Promise => { + async (callbackId: string, callback: (context: IActionContext) => Promise): Promise => { const context = { telemetry: { properties: {}, measurements: {} }, errorHandling: { issueProperties: {} }, ui: {} as unknown, valuesToMask: [], } as unknown as IActionContext; + telemetry.push({ event: callbackId, measurements: context.telemetry.measurements }); try { return await callback(context); } catch (error) { @@ -81,6 +84,15 @@ vi.mock('vscode', () => { }), }, CancellationTokenSource, + LanguageModelTextPart: class { + constructor(public value: string) {} + }, + LanguageModelDataPart: class { + constructor( + public data: Uint8Array, + public mimeType: string, + ) {} + }, lm: { selectChatModels: (...args: unknown[]) => selectChatModelsMock(...args) as unknown }, }; }); @@ -100,20 +112,21 @@ function makeUserMessage(content: string): { role: number; content: string } { /** * Builds a synthetic `LanguageModelChat` whose `sendRequest` returns a - * response whose `.text` is an async iterable of the supplied fragments. + * response whose `.stream` contains text parts followed by optional data parts. * `countTokens` is wired to a tiny deterministic estimate so the * usage-on-completion assertion has stable expected values. */ -function makeFakeModel(fragments: string[]): unknown { +function makeFakeModel(fragments: string[], extraParts: unknown[] = []): unknown { const sendRequest = vi.fn(async () => { const text = (async function* () { for (const fragment of fragments) { // Yield on a microtask so consumers can interleave aborts. await Promise.resolve(); - yield fragment; + yield new LanguageModelTextPart(fragment); } + yield* extraParts; })(); - return { text }; + return { stream: text }; }); return { id: 'fake-model-id', @@ -133,6 +146,7 @@ function makeFakeModel(fragments: string[]): unknown { describe('CopilotService.streamMessage', () => { beforeEach(() => { selectChatModelsMock.mockReset(); + telemetry.length = 0; }); it('yields fragments in order and resolves completion with the full text + usage', async () => { @@ -157,6 +171,98 @@ describe('CopilotService.streamMessage', () => { expect(response.modelDisplayName).toBe('Fake Model'); expect(typeof response.durationMs).toBe('number'); expect(response.usage?.responseTokens).toBe(response.text.length); + expect(response.usage?.usageSource).toBe('estimated'); + expect(response.usage?.credits).toBeUndefined(); + }); + + it.each(['buffered', 'streaming'])('uses provider counts and exact credits in the %s path', async (path) => { + const usage = { + prompt_tokens: 120, + completion_tokens: 45, + total_tokens: 170, + prompt_tokens_details: { cached_tokens: 100 }, + completion_tokens_details: { reasoning_tokens: 20 }, + copilot_usage: { total_nano_aiu: 12_345_678 }, + }; + selectChatModelsMock.mockResolvedValueOnce([ + makeFakeModel( + ['Hi'], + [ + { ignored: 'tool/thinking part' }, + new LanguageModelDataPart(new TextEncoder().encode(JSON.stringify(usage)), 'usage'), + ], + ), + ]); + const messages = [makeUserMessage('Hello') as never]; + let result; + if (path === 'buffered') { + result = await CopilotService.sendMessage(messages); + } else { + const handle = CopilotService.streamMessage(messages); + const received: string[] = []; + for await (const fragment of handle.fragments) received.push(fragment); + expect(received).toEqual(['Hi']); + result = await handle.completion; + } + expect(result.usage).toMatchObject({ + promptTokens: 120, + responseTokens: 45, + totalTokens: 170, + reportedPromptTokens: 120, + reportedCompletionTokens: 45, + cachedPromptTokens: 100, + reasoningTokens: 20, + credits: 0.012345678, + usageSource: 'provider', + }); + expect( + telemetry.find((entry) => entry.event.endsWith(path === 'buffered' ? '.sendMessage' : '.streamMessage')) + ?.measurements, + ).toMatchObject({ credits: 0.012345678, reportedPromptTokens: 120, reportedCompletionTokens: 45 }); + }); + + it('keeps zero credits/counts and estimates only missing provider token fields', async () => { + selectChatModelsMock.mockResolvedValueOnce([ + makeFakeModel( + ['Hello'], + [ + new LanguageModelDataPart( + new TextEncoder().encode( + JSON.stringify({ + prompt_tokens: 0, + copilot_usage: { total_nano_aiu: 0 }, + }), + ), + 'usage', + ), + new LanguageModelDataPart(new TextEncoder().encode('ignored'), 'other'), + ], + ), + ]); + const result = await CopilotService.sendMessage([makeUserMessage('Hello') as never]); + expect(result.usage).toMatchObject({ + promptTokens: 0, + responseTokens: 5, + totalTokens: 5, + reportedPromptTokens: 0, + credits: 0, + usageSource: 'provider', + }); + expect(result.usage?.reportedCompletionTokens).toBeUndefined(); + }); + + it.each([ + '{bad', + 'null', + '{"prompt_tokens":"invalid","completion_tokens":-5,"copilot_usage":{"total_nano_aiu":"bad"}}', + ])('falls back safely on malformed usage: %s', async (data) => { + selectChatModelsMock.mockResolvedValueOnce([ + makeFakeModel(['Safe text'], [new LanguageModelDataPart(new TextEncoder().encode(data), 'usage')]), + ]); + const result = await CopilotService.sendMessage([makeUserMessage('Hello') as never]); + expect(result.text).toBe('Safe text'); + expect(result.usage).toMatchObject({ responseTokens: 9, usageSource: 'estimated' }); + expect(result.usage?.credits).toBeUndefined(); }); it('ends iteration and rejects completion when the signal is aborted mid-stream', async () => { diff --git a/src/services/copilotService.ts b/src/services/copilotService.ts index d9a9339e9..cb762325f 100644 --- a/src/services/copilotService.ts +++ b/src/services/copilotService.ts @@ -12,6 +12,7 @@ import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; import { ext } from '../extensionVariables'; import { formatTokenCount } from '../utils/formatTokenCount'; +import { resolveQueryInsightsModel } from './ai/modelSelection'; /** * Identifies which extension feature is calling `CopilotService` so we can @@ -36,6 +37,16 @@ export interface CopilotMessageOptions { * call that should be attributable in analytics. */ featureSource?: CopilotFeatureSource; + + /** + * POC: `'user'` resolves the model from the Query Insights model picker + * (explicit `modelId` → saved preference → first available). Defaults to + * the free `copilot-utility` alias. + */ + modelSelection?: 'utility' | 'user'; + + /** POC: explicit model id, only honoured with `modelSelection: 'user'`. */ + modelId?: string; } /** @@ -58,16 +69,16 @@ export interface CopilotMessageOptions { * so we do NOT attempt to target those families directly — calling them by * name from a third-party extension would fall outside the chat-fallback * path and bill the user. The alias is the only target that preserves the - * "free for the user" guarantee, and is therefore intentionally the only - * model this service requests. + * "free for the user" guarantee. Query generation retains this default; + * Query Insights can explicitly opt into user-selected models. */ const UTILITY_MODEL_FAMILY = 'copilot-utility'; /** * Token-usage metrics for a Copilot request. * - * The VS Code Language Model API does not expose token counts on responses, - * so we compute them client-side via `LanguageModelChat.countTokens(...)`. + * Provider usage data parts take precedence over best-effort client-side + * `LanguageModelChat.countTokens(...)` estimates. * All fields are optional because token counting can fail (e.g., when the * model rejects the count request, or when the request is cancelled mid-flight) * and we never want to fail the user-facing flow because telemetry is @@ -84,6 +95,13 @@ export interface CopilotTokenUsage { maxInputTokens?: number; /** `promptTokens / maxInputTokens * 100`, rounded to one decimal place. */ promptUtilizationPct?: number; + reportedPromptTokens?: number; + reportedCompletionTokens?: number; + cachedPromptTokens?: number; + reasoningTokens?: number; + /** Exact billed AI credits, not a calculation from model pricing. */ + credits?: number; + usageSource?: 'provider' | 'estimated'; } /** @@ -200,10 +218,10 @@ export class CopilotService { // review rather than at runtime). context.telemetry.properties.featureSource = options?.featureSource ?? 'unknown'; - const selectedModel = await this.selectUtilityModel(); + const { model: selectedModel, outcome } = await this.selectModel(options); if (!selectedModel) { - context.telemetry.properties.modelSelectionOutcome = 'no-utility-model-available'; + context.telemetry.properties.modelSelectionOutcome = outcome; throw new Error( l10n.t( 'No suitable language model is available. Please ensure GitHub Copilot is installed and signed in with an active subscription, and that you accepted the language-model access consent prompt the first time this feature was used (you can re-trigger it by running the feature again).', @@ -218,7 +236,7 @@ export class CopilotService { if (typeof selectedModel.maxInputTokens === 'number') { context.telemetry.measurements.modelMaxInputTokens = selectedModel.maxInputTokens; } - context.telemetry.properties.modelSelectionOutcome = 'utility-model'; + context.telemetry.properties.modelSelectionOutcome = outcome; try { const response = await this.sendToModel(selectedModel, messages, options); @@ -244,6 +262,7 @@ export class CopilotService { if (promptUtilizationPct !== undefined) { context.telemetry.measurements.promptUtilizationPct = promptUtilizationPct; } + this.recordReportedUsage(context, response.usage); } return { @@ -343,10 +362,10 @@ export class CopilotService { // calling feature so analytics can split telemetry by source. context.telemetry.properties.featureSource = options?.featureSource ?? 'unknown'; - const selectedModel = await this.selectUtilityModel(); + const { model: selectedModel, outcome } = await this.selectModel(options); if (!selectedModel) { - context.telemetry.properties.modelSelectionOutcome = 'no-utility-model-available'; + context.telemetry.properties.modelSelectionOutcome = outcome; throw new Error( l10n.t( 'No suitable language model is available. Please ensure GitHub Copilot is installed and signed in with an active subscription, and that you accepted the language-model access consent prompt the first time this feature was used (you can re-trigger it by running the feature again).', @@ -361,7 +380,7 @@ export class CopilotService { if (typeof selectedModel.maxInputTokens === 'number') { context.telemetry.measurements.modelMaxInputTokens = selectedModel.maxInputTokens; } - context.telemetry.properties.modelSelectionOutcome = 'utility-model'; + context.telemetry.properties.modelSelectionOutcome = outcome; try { const response = await this.streamToModel(selectedModel, messages, options, (fragment) => @@ -386,6 +405,7 @@ export class CopilotService { if (promptUtilizationPct !== undefined) { context.telemetry.measurements.promptUtilizationPct = promptUtilizationPct; } + this.recordReportedUsage(context, response.usage); } return { @@ -439,20 +459,26 @@ export class CopilotService { ); } - /** - * Resolves the Copilot **utility** model. - * - * We always ask for {@link UTILITY_MODEL_FAMILY} (`copilot-utility`) — the - * Copilot extension's documented alias for the chat-fallback path — and - * never fall back to billed picker models. If the alias is unavailable - * (e.g. the Copilot extension is not installed, the user is signed out, - * or the consent prompt has not been accepted) the caller is responsible - * for surfacing the failure; we never silently degrade onto a model that - * would consume the user's premium request budget. - * - * Returns `undefined` when no model is available so the caller can map - * that to its own user-facing error. - */ + /** User model selection is opt-in; other callers remain utility-only. */ + private static async selectModel( + options: CopilotMessageOptions | undefined, + ): Promise<{ model: vscode.LanguageModelChat | undefined; outcome: string }> { + if (options?.modelSelection !== 'user') { + const model = await this.selectUtilityModel(); + return { model, outcome: model ? 'utility-model' : 'no-utility-model-available' }; + } + + const resolved = await resolveQueryInsightsModel(options.modelId); + if (!resolved) { + return { model: undefined, outcome: 'no-model-available' }; + } + ext.outputChannel.trace( + l10n.t('[Copilot] Selected model ({0}): {1}', resolved.source, this.formatModelDetails(resolved.model)), + ); + return { model: resolved.model, outcome: `user-${resolved.source}` }; + } + + /** Never fall back to a billed model for utility-only requests. */ private static async selectUtilityModel(): Promise { const matches = await vscode.lm.selectChatModels({ vendor: 'copilot', @@ -551,10 +577,19 @@ export class CopilotService { // Collect the streaming response, checking for cancellation between chunks let fullResponse = ''; let firstTokenTraced = false; - for await (const fragment of chatResponse.text) { + let reportedUsage: Partial = {}; + for await (const part of chatResponse.stream) { if (signal?.aborted) { break; } + if (part instanceof vscode.LanguageModelDataPart && part.mimeType === 'usage') { + reportedUsage = { ...reportedUsage, ...this.readProviderUsage(part.data) }; + continue; + } + if (!(part instanceof vscode.LanguageModelTextPart)) { + continue; + } + const fragment = part.value; if (!firstTokenTraced) { firstTokenTraced = true; // Time-to-first-token: the headline latency users feel as @@ -588,18 +623,21 @@ export class CopilotService { // reject (e.g., if the model rejects the count request); we never want // a telemetry failure to break the user-facing flow, so each step is // wrapped in its own try/catch and falls back to `undefined`. - const usage = await this.measureTokenUsage(model, messages, fullResponse); + const usage = await this.measureTokenUsage(model, messages, fullResponse, reportedUsage); // Trace token usage in compact form so the output channel stays scannable. // The numeric measurements are still emitted to telemetry verbatim. + const locale = vscode.env?.language || 'en-US'; ext.outputChannel.trace( l10n.t( - '[Copilot] Tokens: prompt={0}, response={1}, total={2}, context={3}, utilization={4}%', - usage.promptTokens !== undefined ? formatTokenCount(usage.promptTokens) : '?', - usage.responseTokens !== undefined ? formatTokenCount(usage.responseTokens) : '?', - usage.totalTokens !== undefined ? formatTokenCount(usage.totalTokens) : '?', - usage.maxInputTokens !== undefined ? formatTokenCount(usage.maxInputTokens) : '?', + '[Copilot] Usage ({5}): prompt={0}, response={1}, total={2}, context={3}, utilization={4}%, credits={6}', + usage.promptTokens !== undefined ? formatTokenCount(usage.promptTokens, locale) : '?', + usage.responseTokens !== undefined ? formatTokenCount(usage.responseTokens, locale) : '?', + usage.totalTokens !== undefined ? formatTokenCount(usage.totalTokens, locale) : '?', + usage.maxInputTokens !== undefined ? formatTokenCount(usage.maxInputTokens, locale) : '?', usage.promptUtilizationPct !== undefined ? usage.promptUtilizationPct.toString() : '?', + usage.usageSource ?? 'estimated', + usage.credits !== undefined ? String(usage.credits) : '?', ), ); @@ -613,16 +651,14 @@ export class CopilotService { /** * Computes best-effort token counts for the prompt and response. * - * The VS Code Language Model API does not return token usage on responses, - * so we ask the model to count tokens client-side via `countTokens`. Both - * the prompt and the response counts are issued in parallel to minimise - * latency, and any failure is logged and degraded to `undefined` instead - * of bubbling up to the caller. + * Count only missing provider fields, in parallel. Failures degrade to + * undefined rather than interrupting the response. */ private static async measureTokenUsage( model: vscode.LanguageModelChat, messages: vscode.LanguageModelChatMessage[], responseText: string, + reported: Partial, ): Promise { const maxInputTokens = typeof model.maxInputTokens === 'number' ? model.maxInputTokens : undefined; @@ -641,25 +677,80 @@ export class CopilotService { // Count prompt tokens per-message in parallel, then sum. If any single // message count fails the total is still useful as a lower bound, but // we err on the side of reporting `undefined` to avoid skewed metrics. - const [perMessageCounts, responseTokens] = await Promise.all([ - Promise.all(messages.map((m) => countSafely(m))), - responseText.length > 0 ? countSafely(responseText) : Promise.resolve(0), + const [perMessageCounts, estimatedResponseTokens] = await Promise.all([ + reported.reportedPromptTokens === undefined + ? Promise.all(messages.map((m) => countSafely(m))) + : Promise.resolve<(number | undefined)[]>([]), + reported.reportedCompletionTokens !== undefined + ? undefined + : responseText.length > 0 + ? countSafely(responseText) + : Promise.resolve(0), ]); - let promptTokens: number | undefined; - if (perMessageCounts.every((c) => typeof c === 'number')) { + let promptTokens = reported.reportedPromptTokens; + if (promptTokens === undefined && perMessageCounts.every((c) => typeof c === 'number')) { promptTokens = perMessageCounts.reduce((sum, c) => sum + (c as number), 0); } + const responseTokens = reported.reportedCompletionTokens ?? estimatedResponseTokens; const totalTokens = - promptTokens !== undefined && responseTokens !== undefined ? promptTokens + responseTokens : undefined; + reported.totalTokens ?? + (promptTokens !== undefined && responseTokens !== undefined ? promptTokens + responseTokens : undefined); let promptUtilizationPct: number | undefined; if (promptTokens !== undefined && maxInputTokens && maxInputTokens > 0) { promptUtilizationPct = Math.round((promptTokens / maxInputTokens) * 1000) / 10; } - return { promptTokens, responseTokens, totalTokens, maxInputTokens, promptUtilizationPct }; + return { + ...reported, + promptTokens, + responseTokens, + totalTokens, + maxInputTokens, + promptUtilizationPct, + usageSource: Object.keys(reported).length > 0 ? 'provider' : 'estimated', + }; + } + + private static recordReportedUsage(context: IActionContext, usage: CopilotTokenUsage): void { + for (const key of ['credits', 'reportedPromptTokens', 'reportedCompletionTokens'] as const) { + if (usage[key] !== undefined) { + context.telemetry.measurements[key] = usage[key]; + } + } + } + + private static readProviderUsage(data: Uint8Array): Partial { + try { + const parsed: unknown = JSON.parse(new TextDecoder().decode(data)); + const object = (value: unknown): Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; + const number = (value: unknown): number | undefined => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined; + const usage = object(parsed); + const fields = { + reportedPromptTokens: number(usage.prompt_tokens), + reportedCompletionTokens: number(usage.completion_tokens), + totalTokens: number(usage.total_tokens), + cachedPromptTokens: number(object(usage.prompt_tokens_details).cached_tokens), + reasoningTokens: number(object(usage.completion_tokens_details).reasoning_tokens), + credits: number(object(usage.copilot_usage).total_nano_aiu), + }; + const result: Partial = {}; + for (const key of Object.keys(fields) as (keyof typeof fields)[]) { + const value = fields[key]; + if (value !== undefined) { + result[key] = key === 'credits' ? value / 1_000_000_000 : value; + } + } + return result; + } catch { + return {}; + } } /** @@ -683,12 +774,13 @@ export class CopilotService { * Dumps the selected model's stable metadata (and any other own enumerable * properties present at runtime) to the trace stream. * - * Pricing and cost-per-token fields live on the proposed - * `languageModelPricing` API and are intentionally **not** consumed here — - * the extension stays on stable VS Code APIs. This dump is purely a + * Shipped code deliberately avoided the proposed `languageModelPricing` + * API. Query Insights now defensively reads its ungated runtime properties + * in modelSelection (F7/F10), without declaring enabledApiProposals. + * This dump remains purely a * diagnostic so we can observe what the runtime exposes today (and tell * when richer information becomes available on stable in the future) - * without depending on it. + * without assuming these undocumented fields remain available. * * Memoised by model id within a single extension host process — the * metadata is static for a given id, so we only need to emit it the first @@ -713,9 +805,7 @@ export class CopilotService { }; ext.outputChannel.trace(l10n.t('[Copilot] Selected model metadata: {0}', JSON.stringify(stable))); - // 2) Best-effort runtime introspection. We never read pricing or other - // proposed-API fields by name; we only enumerate whatever the host - // happens to expose and skip methods. This is observation only. + // 2) Best-effort diagnostic introspection, skipping methods. try { const own: Record = {}; for (const [key, value] of Object.entries(model as unknown as Record)) { diff --git a/src/services/promptTemplateService.test.ts b/src/services/promptTemplateService.test.ts new file mode 100644 index 000000000..b7a8c676c --- /dev/null +++ b/src/services/promptTemplateService.test.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CommandType } from '../commands/llmEnhancedCommands/indexAdvisorCommands'; +import { getLastPromptSource } from '../commands/llmEnhancedCommands/promptTemplates'; +import { getIndexAdvisorPromptSourceInfo, PromptTemplateService } from './promptTemplateService'; + +const mocks = vi.hoisted(() => ({ + settings: new Map(), + readFile: vi.fn(), + access: vi.fn(), + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + trace: vi.fn(), +})); + +vi.mock('fs/promises', () => ({ access: mocks.access, readFile: mocks.readFile })); +vi.mock('vscode', () => ({ + workspace: { + getConfiguration: () => ({ + get: (key: string, fallback?: unknown): unknown => mocks.settings.get(key) ?? fallback, + }), + workspaceFolders: [], + }, + window: { + showInformationMessage: mocks.showInformationMessage, + showWarningMessage: mocks.showWarningMessage, + }, + l10n: { + t: (message: string, args?: Record): string => + message.replace(/\{(\w+)\}/g, (match, key: string) => (args && key in args ? String(args[key]) : match)), + }, +})); +vi.mock('../extensionVariables', () => ({ ext: { outputChannel: { trace: mocks.trace } } })); +vi.mock('@microsoft/vscode-azext-utils', () => ({ + callWithTelemetryAndErrorHandling: async ( + _event: string, + callback: (context: IActionContext) => Promise, + ): Promise => { + try { + return await callback({ + telemetry: { properties: {}, measurements: {} }, + errorHandling: {}, + } as IActionContext); + } catch { + return undefined; + } + }, +})); +vi.mock('../commands/llmEnhancedCommands/indexAdvisorCommands', () => ({ + CommandType: { Find: 'find', Aggregate: 'aggregate', Count: 'count' }, +})); +vi.mock('../commands/llmEnhancedCommands/queryGenerationCommands', () => ({ + QueryGenerationType: { SingleCollection: 'single', CrossCollection: 'cross' }, +})); + +describe('Index advisor prompt iteration', () => { + const customSetting = 'documentDB.aiAssistant.indexAdvisorFindPromptPath'; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.settings.clear(); + PromptTemplateService.clearCache(); + mocks.access.mockResolvedValue(undefined); + }); + + it('reads every custom-file edit even when a built-in prompt was already cached', async () => { + const builtIn = await PromptTemplateService.getIndexAdvisorPromptTemplate(CommandType.Find); + mocks.settings.set(customSetting, 'prompts/local.md'); + mocks.readFile.mockResolvedValueOnce('First edit').mockResolvedValueOnce('Second edit'); + + expect(await PromptTemplateService.getIndexAdvisorPromptTemplate(CommandType.Find)).toBe('First edit'); + expect(await PromptTemplateService.getIndexAdvisorPromptTemplate(CommandType.Find)).toBe('Second edit'); + expect(getLastPromptSource()).toBe('custom-file'); + expect(mocks.readFile).toHaveBeenCalledTimes(2); + expect(mocks.trace.mock.calls.map(([message]) => message as string)).toEqual([ + expect.stringMatching(/^Query Insights prompt for find query: built-in \((resource-file|inline-fallback)/), + 'Query Insights prompt for find query: custom file prompts/local.md', + 'Query Insights prompt for find query: custom file prompts/local.md', + ]); + expect(mocks.showInformationMessage).not.toHaveBeenCalled(); + + mocks.settings.delete(customSetting); + expect(await PromptTemplateService.getIndexAdvisorPromptTemplate(CommandType.Find)).toBe(builtIn); + expect(getLastPromptSource()).not.toBe('custom-file'); + // Served from the cache, still traced. + expect(mocks.trace).toHaveBeenCalledTimes(4); + expect(mocks.trace.mock.lastCall?.[0]).toMatch(/^Query Insights prompt for find query: built-in \(/); + }); + + it('does not cache a failed custom-file read and retries on the next request', async () => { + mocks.settings.set(customSetting, 'prompts/local.md'); + mocks.readFile.mockRejectedValueOnce(new Error('not yet saved')).mockResolvedValueOnce('Fixed prompt'); + expect(await PromptTemplateService.getIndexAdvisorPromptTemplate(CommandType.Find)).not.toBe('Fixed prompt'); + expect(mocks.showWarningMessage).toHaveBeenCalledOnce(); + expect(mocks.trace.mock.lastCall?.[0]).toMatch( + /^Query Insights prompt for find query: built-in \(.+\); the custom file prompts\/local\.md could not be read$/, + ); + expect(await PromptTemplateService.getIndexAdvisorPromptTemplate(CommandType.Find)).toBe('Fixed prompt'); + }); + + it('reports the configured source for every command type without reading the file', () => { + expect(getIndexAdvisorPromptSourceInfo(CommandType.Find)).toEqual({ source: 'built-in' }); + for (const [commandType, setting] of [ + [CommandType.Find, 'documentDB.aiAssistant.indexAdvisorFindPromptPath'], + [CommandType.Aggregate, 'documentDB.aiAssistant.indexAdvisorAggregatePromptPath'], + [CommandType.Count, 'documentDB.aiAssistant.indexAdvisorCountPromptPath'], + ] as const) { + mocks.settings.set(setting, 'prompts/private.md'); + expect(getIndexAdvisorPromptSourceInfo(commandType)).toEqual({ + source: 'custom-file', + path: 'prompts/private.md', + }); + } + expect(mocks.readFile).not.toHaveBeenCalled(); + }); + + it('treats an empty or whitespace-only path as not configured and uses the built-in prompt', async () => { + const builtIn = await PromptTemplateService.getIndexAdvisorPromptTemplate(CommandType.Find); + PromptTemplateService.clearCache(); + for (const blank of ['', ' ', '\t\n', null]) { + mocks.settings.set(customSetting, blank); + expect(getIndexAdvisorPromptSourceInfo(CommandType.Find)).toEqual({ source: 'built-in' }); + expect(await PromptTemplateService.getIndexAdvisorPromptTemplate(CommandType.Find)).toBe(builtIn); + } + expect(mocks.readFile).not.toHaveBeenCalled(); + expect(mocks.showWarningMessage).not.toHaveBeenCalled(); + }); + + it('trims surrounding whitespace from a configured custom path', () => { + mocks.settings.set(customSetting, ' prompts/local.md \n'); + expect(getIndexAdvisorPromptSourceInfo(CommandType.Find)).toEqual({ + source: 'custom-file', + path: 'prompts/local.md', + }); + }); +}); diff --git a/src/services/promptTemplateService.ts b/src/services/promptTemplateService.ts index c82c79c6e..fac23ae84 100644 --- a/src/services/promptTemplateService.ts +++ b/src/services/promptTemplateService.ts @@ -21,8 +21,31 @@ import { type PromptSource, } from '../commands/llmEnhancedCommands/promptTemplates'; import { QueryGenerationType } from '../commands/llmEnhancedCommands/queryGenerationCommands'; +import { ext } from '../extensionVariables'; import { settingsKeys } from '../settingsKeys'; +export interface IndexAdvisorPromptSourceInfo { + source: 'built-in' | 'custom-file'; + path?: string; +} + +const indexAdvisorConfigKeys: Record = { + [CommandType.Find]: settingsKeys.indexAdvisorFindPromptPath, + [CommandType.Aggregate]: settingsKeys.indexAdvisorAggregatePromptPath, + [CommandType.Count]: settingsKeys.indexAdvisorCountPromptPath, +}; + +/** Describes the configured source without reading the prompt file. */ +export function getIndexAdvisorPromptSourceInfo(commandType: CommandType): IndexAdvisorPromptSourceInfo { + const configKey = indexAdvisorConfigKeys[commandType]; + if (!configKey) { + throw new Error(vscode.l10n.t('Unknown command type: {type}', { type: commandType })); + } + // An empty or whitespace-only setting means "not configured": fall back to the built-in prompt. + const customPath = vscode.workspace.getConfiguration().get(configKey)?.trim(); + return customPath ? { source: 'custom-file', path: customPath } : { source: 'built-in' }; +} + /** * Service for loading prompt templates from custom files or built-in templates */ @@ -42,23 +65,20 @@ export class PromptTemplateService { public static async getIndexAdvisorPromptTemplate(commandType: CommandType): Promise { // Get configuration const config = vscode.workspace.getConfiguration(); - const cacheEnabled = config.get(this.enablePromptCacheKey, true); + const promptSource = getIndexAdvisorPromptSourceInfo(commandType); + const customTemplatePath = promptSource.path; + const cacheEnabled = !customTemplatePath && config.get(this.enablePromptCacheKey, true); // Check if have a cached template if (cacheEnabled) { const cached = this.templateCache.get(commandType); if (cached) { setLastPromptSource(cached.source); + this.traceIndexAdvisorPrompt(commandType, cached.source); return cached.template; } } - // Get the configuration key for this command type - const configKey = this.getIndexAdvisorConfigKey(commandType); - - // Check if a custom template path is configured - const customTemplatePath = config.get(configKey); - let template: string; if (customTemplatePath) { @@ -66,12 +86,6 @@ export class PromptTemplateService { // Load custom template from file template = await this.loadTemplateFromFile(customTemplatePath, commandType.toString()); setLastPromptSource('custom-file'); - void vscode.window.showInformationMessage( - l10n.t('Using custom prompt template for {type} query: {path}', { - type: commandType, - path: customTemplatePath, - }), - ); } catch (error) { // Log telemetry for template load operation (automatically includes result='Failed' when error is thrown) void callWithTelemetryAndErrorHandling( @@ -104,9 +118,36 @@ export class PromptTemplateService { this.templateCache.set(commandType, { template, source }); } + this.traceIndexAdvisorPrompt(commandType, source, customTemplatePath); return template; } + /** The UI no longer shows the prompt source; the output channel records it for every request. */ + private static traceIndexAdvisorPrompt( + commandType: CommandType, + source: PromptSource, + customTemplatePath?: string, + ): void { + let message: string; + if (source === 'custom-file') { + message = vscode.l10n.t('Query Insights prompt for {type} query: custom file {path}', { + type: commandType, + path: customTemplatePath ?? '', + }); + } else if (customTemplatePath) { + message = vscode.l10n.t( + 'Query Insights prompt for {type} query: built-in ({source}); the custom file {path} could not be read', + { type: commandType, source, path: customTemplatePath }, + ); + } else { + message = vscode.l10n.t('Query Insights prompt for {type} query: built-in ({source})', { + type: commandType, + source, + }); + } + ext.outputChannel.trace(message); + } + /** * Gets the prompt template for query generation * @param generationType The type of query generation (cross-collection or single-collection) @@ -165,24 +206,6 @@ export class PromptTemplateService { this.templateCache.clear(); } - /** - * Gets the configuration key for a command type - * @param commandType The command type - * @returns The configuration key - */ - private static getIndexAdvisorConfigKey(commandType: CommandType): string { - switch (commandType) { - case CommandType.Find: - return settingsKeys.indexAdvisorFindPromptPath; - case CommandType.Aggregate: - return settingsKeys.indexAdvisorAggregatePromptPath; - case CommandType.Count: - return settingsKeys.indexAdvisorCountPromptPath; - default: - throw new Error(l10n.t('Unknown command type: {type}', { type: commandType })); - } - } - /** * Gets the configuration key for a query generation type * @param generationType The query generation type diff --git a/src/tree/help-and-feedback-view/HelpAndFeedbackBranchDataProvider.ts b/src/tree/help-and-feedback-view/HelpAndFeedbackBranchDataProvider.ts index cbbe4d408..5e8ce4f37 100644 --- a/src/tree/help-and-feedback-view/HelpAndFeedbackBranchDataProvider.ts +++ b/src/tree/help-and-feedback-view/HelpAndFeedbackBranchDataProvider.ts @@ -73,6 +73,13 @@ export class HelpAndFeedbackBranchDataProvider extends BaseExtendedTreeDataProvi const parentId = Views.HelpAndFeedbackView; const rootItems: TreeElement[] = [ + createGenericElement({ + contextValue: 'actionItem', + id: `${parentId}/query-insights-explorer`, + label: vscode.l10n.t('Query Insights Explorer (POC)'), + iconPath: new vscode.ThemeIcon('beaker'), + commandId: 'vscode-documentdb.command.queryInsights.exploreWithOwnInput', + }) as TreeElement, createGenericElement({ contextValue: 'helpItem', id: `${parentId}/whats-new`, diff --git a/src/utils/formatTokenCount.ts b/src/utils/formatTokenCount.ts index e8b24cd23..5a1976ba0 100644 --- a/src/utils/formatTokenCount.ts +++ b/src/utils/formatTokenCount.ts @@ -3,8 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as vscode from 'vscode'; - /** * Formats a token count for display in trace logs and telemetry-driven UI. * @@ -14,9 +12,10 @@ import * as vscode from 'vscode'; * supply their own unit/suffix (e.g., `"tokens"`, `"of context"`). * * @param count The token count to format + * @param locale Display locale (host callers can pass the VS Code locale). * @returns Formatted string representation of the count */ -export function formatTokenCount(count: number): string { +export function formatTokenCount(count: number, locale = 'en-US'): string { if (!Number.isFinite(count) || count < 0) { return String(count); } @@ -26,9 +25,6 @@ export function formatTokenCount(count: number): string { } // Use Intl.NumberFormat for compact notation - // Try to use the user's VS Code locale, fall back to 'en-US' if unavailable - const locale = vscode.env?.language || 'en-US'; - const formatter = new Intl.NumberFormat(locale, { notation: 'compact', maximumFractionDigits: 1, diff --git a/src/webviews/_integration/WebviewRegistry.test.tsx b/src/webviews/_integration/WebviewRegistry.test.tsx index 7c30f8994..c6c911f17 100644 --- a/src/webviews/_integration/WebviewRegistry.test.tsx +++ b/src/webviews/_integration/WebviewRegistry.test.tsx @@ -16,6 +16,7 @@ const moduleLoads = vi.hoisted(() => ({ documentView: vi.fn(() => ({ DocumentView: (): string => 'documentView' })), localQuickStart: vi.fn(() => ({ LocalQuickStart: (): string => 'localQuickStart' })), atlasCredentials: vi.fn(() => ({ AtlasCredentialsView: (): string => 'atlasCredentials' })), + queryInsightsStandalone: vi.fn(() => ({ QueryInsightsStandalone: (): string => 'queryInsightsStandalone' })), })); vi.mock('../documentdb/clusterDashboard/ClusterDashboard', moduleLoads.clusterDashboard); @@ -23,13 +24,19 @@ vi.mock('../documentdb/collectionView/CollectionView', moduleLoads.collectionVie vi.mock('../documentdb/documentView/documentView', moduleLoads.documentView); vi.mock('../documentdb/localQuickStart/LocalQuickStart', moduleLoads.localQuickStart); vi.mock('../documentdb/atlasCredentials/AtlasCredentialsView', moduleLoads.atlasCredentials); +vi.mock('../documentdb/queryInsightsStandalone/QueryInsightsStandalone', moduleLoads.queryInsightsStandalone); (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; describe('WebviewRegistry', () => { it('loads only the selected root module and renders each named export through Suspense', async () => { expectTypeOf().toEqualTypeOf< - 'clusterDashboard' | 'collectionView' | 'documentView' | 'localQuickStart' | 'atlasCredentials' + | 'clusterDashboard' + | 'collectionView' + | 'documentView' + | 'localQuickStart' + | 'atlasCredentials' + | 'queryInsightsStandalone' >(); expect(Object.keys(WebviewRegistry).sort()).toEqual(Object.keys(moduleLoads).sort()); for (const load of Object.values(moduleLoads)) { @@ -41,6 +48,7 @@ describe('WebviewRegistry', () => { const root = createRoot(host); const loaded = new Set(); const names: WebviewName[] = [ + 'queryInsightsStandalone', 'atlasCredentials', 'localQuickStart', 'documentView', diff --git a/src/webviews/_integration/WebviewRegistry.ts b/src/webviews/_integration/WebviewRegistry.ts index 603039ceb..84145bc1c 100644 --- a/src/webviews/_integration/WebviewRegistry.ts +++ b/src/webviews/_integration/WebviewRegistry.ts @@ -31,6 +31,11 @@ import * as React from 'react'; * {@link WebviewName} enforces that at compile time. */ export const WebviewRegistry = { + queryInsightsStandalone: React.lazy(() => + import('../documentdb/queryInsightsStandalone/QueryInsightsStandalone').then((module) => ({ + default: module.QueryInsightsStandalone, + })), + ), clusterDashboard: React.lazy(() => import('../documentdb/clusterDashboard/ClusterDashboard').then((module) => ({ default: module.ClusterDashboard, diff --git a/src/webviews/_integration/appRouter.ts b/src/webviews/_integration/appRouter.ts index 227b6c07d..67b3f4e81 100644 --- a/src/webviews/_integration/appRouter.ts +++ b/src/webviews/_integration/appRouter.ts @@ -39,6 +39,8 @@ import { collectionsViewRouter as collectionViewRouter } from '../documentdb/col import { indexViewRouter } from '../documentdb/collectionView/indexesTab/indexViewRouter'; import { documentsViewRouter as documentViewRouter } from '../documentdb/documentView/documentsViewRouter'; import { localQuickStartRouter } from '../documentdb/localQuickStart/localQuickStartRouter'; +import { queryInsightsModelsRouter } from '../documentdb/queryInsightsShared/queryInsightsModelsRouter'; +import { queryInsightsStandaloneRouter } from '../documentdb/queryInsightsStandalone/queryInsightsStandaloneRouter'; import { WEBVIEW_CONFIG } from './configuration'; import { publicProcedure, publicProcedureWithTelemetry, router } from './trpc'; @@ -91,6 +93,10 @@ export type BaseRouterContext = FrameworkBaseRouterContext & { measurements?: Record */ const commonRouter = router({ + copyToClipboard: publicProcedure.input(z.object({ text: z.string() })).mutation(async ({ input }) => { + await vscode.env.clipboard.writeText(input.text); + return true; + }), openFeedback: publicProcedure .input(z.object({ collectionTab: z.enum(['documents', 'indexes', 'queryInsights']).optional() })) .mutation(async ({ input, ctx }): Promise => { @@ -275,6 +281,8 @@ const commonRouter = router({ }); export const appRouter = router({ + queryInsightsStandalone: queryInsightsStandaloneRouter, + ai: queryInsightsModelsRouter, common: commonRouter, atlasCredentials: atlasCredentialsRouter, clusterDashboard: clusterDashboardRouter, diff --git a/src/webviews/documentdb/collectionView/CollectionView.tsx b/src/webviews/documentdb/collectionView/CollectionView.tsx index 69c589c57..12294c3dc 100644 --- a/src/webviews/documentdb/collectionView/CollectionView.tsx +++ b/src/webviews/documentdb/collectionView/CollectionView.tsx @@ -32,6 +32,7 @@ import { ToolbarViewNavigation } from './components/toolbar/ToolbarViewNavigatio import { ViewSwitcher } from './components/toolbar/ViewSwitcher'; import { IndexesTab } from './indexesTab/IndexesTab'; import { stage1Failed, stage1Succeeded, startStage1Load } from './queryInsightsReducer'; +import { CollectionQueryInsightsProvider } from './queryInsightsTab/QueryInsightsDataSourceContext'; import { QueryInsightsMain } from './queryInsightsTab/QueryInsightsTab'; import { extractErrorCode } from './utils/errorCodeExtractor'; @@ -767,7 +768,11 @@ export const CollectionView = (): JSX.Element => { {selectedTab === 'tab_indexes' && } - {selectedTab === 'tab_queryInsights' && } + {selectedTab === 'tab_queryInsights' && ( + + + + )} diff --git a/src/webviews/documentdb/collectionView/collectionViewContext.ts b/src/webviews/documentdb/collectionView/collectionViewContext.ts index 0239f9ec7..9aedb33b8 100644 --- a/src/webviews/documentdb/collectionView/collectionViewContext.ts +++ b/src/webviews/documentdb/collectionView/collectionViewContext.ts @@ -177,6 +177,14 @@ export interface QueryInsightsStage3Model { * Surfaced (in seconds) in the post-response "Powered by …" byline. */ durationMs?: number; + /** Data messages sent with the request, used by "copy as Markdown". */ + modelInputs?: QueryInsightsStage3ModelInputs; +} + +export interface QueryInsightsStage3ModelInputs { + userQuery: string; + contextData: string; + promptSource: string; } /** diff --git a/src/webviews/documentdb/collectionView/queryInsights/queryInsightsEventsRouter.ts b/src/webviews/documentdb/collectionView/queryInsights/queryInsightsEventsRouter.ts index 00015a9d6..ec5af26a6 100644 --- a/src/webviews/documentdb/collectionView/queryInsights/queryInsightsEventsRouter.ts +++ b/src/webviews/documentdb/collectionView/queryInsights/queryInsightsEventsRouter.ts @@ -578,6 +578,7 @@ export const queryInsightsEventsRouter = router({ modelFamily: aiResponse.modelFamily, usage: aiResponse.usage, durationMs: elapsed(), + modelInputs: aiResponse.modelInputs, }; if (abortController.signal.aborted) { return; diff --git a/src/webviews/documentdb/collectionView/queryInsights/queryInsightsRouter.ts b/src/webviews/documentdb/collectionView/queryInsights/queryInsightsRouter.ts index 0ade16c31..707fc89b0 100644 --- a/src/webviews/documentdb/collectionView/queryInsights/queryInsightsRouter.ts +++ b/src/webviews/documentdb/collectionView/queryInsights/queryInsightsRouter.ts @@ -24,6 +24,7 @@ * updated. */ +import { EJSON } from 'bson'; import * as fs from 'fs'; import { type Document } from 'mongodb'; import * as path from 'path'; @@ -36,15 +37,12 @@ import { type ExecutionStatsAnalysis, type QueryPlannerAnalysis, } from '../../../../documentdb/queryInsights/ExplainPlanAnalyzer'; -import { StagePropertyExtractor } from '../../../../documentdb/queryInsights/StagePropertyExtractor'; -import { - createFailedQueryResponse, - transformStage1Response, - transformStage2Response, -} from '../../../../documentdb/queryInsights/transformations'; +import { analyzeExplainForStage2 } from '../../../../documentdb/queryInsights/analyzeExplainForStage2'; +import { transformStage1Response } from '../../../../documentdb/queryInsights/transformations'; import { ext } from '../../../../extensionVariables'; import { QueryInsightsAIService } from '../../../../services/ai/QueryInsightsAIService'; import { mergeRouters, publicProcedureWithTelemetry, router, type WithTelemetry } from '../../../_integration/trpc'; +import { type QueryInsightsPrefill } from '../../queryInsightsStandalone/types'; import { type RouterContext } from '../collectionViewRouter'; import { queryInsightsEventsRouter } from './queryInsightsEventsRouter'; @@ -92,6 +90,53 @@ function readQueryInsightsDebugFile(filename: string): Document | null { } export const queryInsightsQueriesRouter = router({ + getExplorePrefill: publicProcedureWithTelemetry.query(async ({ ctx }): Promise => { + const { sessionId, databaseName, collectionName } = ctx as RouterContext; + const session = ClusterSession.getSession(sessionId); + const query = session.getCurrentFindQueryParamsWithObjects(); + const rawExplain = session.getRawExplainOutput(databaseName, collectionName); + const bestEffort = async (operation: Promise): Promise => { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((resolve) => { + timeout = setTimeout(() => resolve(undefined), 1500); + }), + ]); + } catch { + return undefined; + } finally { + if (timeout) clearTimeout(timeout); + } + }; + const client = session.getClient(); + const [metadata, stats, indexes] = await Promise.all([ + bestEffort(client.getClusterMetadata()), + bestEffort(client.getCollectionStats(databaseName, collectionName)), + bestEffort(client.getIndexStats(databaseName, collectionName)), + ]); + return { + databaseName, + collectionName, + commandType: 'find', + query: EJSON.stringify( + { + filter: query.filterObj, + sort: query.sortObj, + projection: query.projectionObj, + skip: query.skip, + limit: query.limit, + }, + { relaxed: true }, + 2, + ), + explain: rawExplain ? EJSON.stringify(rawExplain, { relaxed: true }, 2) : '', + collectionStats: stats ? EJSON.stringify(stats, { relaxed: true }, 2) : '', + indexes: indexes ? EJSON.stringify(indexes, { relaxed: true }, 2) : '', + clusterType: metadata?.domainInfo_isAzure === 'true' ? 'azure-documentdb' : 'documentdb', + }; + }), /** * Query Insights Stage 1 - Initial View * Returns cheap query plan data using explain("queryPlanner") @@ -229,7 +274,7 @@ export const queryInsightsQueriesRouter = router({ ); let analyzed: ExecutionStatsAnalysis; - let explainResult: Document | undefined; + let explainResult: Document; let totalCollectionDocs: number | undefined; // Check for debug override file first @@ -237,8 +282,6 @@ export const queryInsightsQueriesRouter = router({ let queryFilter: Document | undefined; if (debugData) { ext.outputChannel.trace(l10n.t('[Query Insights Stage 2] Using debug data file')); - // Use debug data - analyze it the same way as real data - analyzed = ExplainPlanAnalyzer.analyzeExecutionStats(debugData); explainResult = debugData; // totalCollectionDocs not available in debug mode — advisories will simply not fire } else { @@ -270,8 +313,7 @@ export const queryInsightsQueriesRouter = router({ }), ); - // Analyze with ExplainPlanAnalyzer (pass the user's actual filter for empty-query detection) - analyzed = ExplainPlanAnalyzer.analyzeExecutionStats(executionStatsResult, queryParams.filterObj); + // The user's actual filter is passed for empty-query detection explainResult = executionStatsResult; queryFilter = queryParams.filterObj as Document | undefined; @@ -284,18 +326,27 @@ export const queryInsightsQueriesRouter = router({ } } - // Extract extended stage info (as per design document) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const executionStages = explainResult?.executionStats?.executionStages as Document | undefined; - if (executionStages) { - analyzed.extendedStageInfo = StagePropertyExtractor.extractAllExtendedStageInfo(executionStages); - - // Enrich with properties from queryPlanner (e.g., isBitmap is only in queryPlanner stages) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const winningPlan = explainResult?.queryPlanner?.winningPlan as Document | undefined; - if (winningPlan) { - StagePropertyExtractor.enrichWithQueryPlannerInfo(analyzed.extendedStageInfo, winningPlan); + let transformed: ReturnType; + try { + analyzed = ExplainPlanAnalyzer.analyzeExecutionStats(explainResult, queryFilter); + transformed = analyzeExplainForStage2(explainResult, totalCollectionDocs, queryFilter, 'find', analyzed); + } catch (error) { + // The explain is already cached in the session, so "View Raw Execution Stats" still works; + // the plan is logged as well so the failure can be reproduced. + const message = error instanceof Error ? error.message : String(error); + ext.outputChannel.error( + l10n.t('[Query Insights Stage 2] Analysis of the execution plan failed: {error}', { error: message }), + ); + if (error instanceof Error && error.stack) { + ext.outputChannel.error(error.stack); } + ext.outputChannel.error( + l10n.t('[Query Insights Stage 2] Execution plan that failed analysis:\n{plan}', { + plan: EJSON.stringify(explainResult, { relaxed: true }, 2), + }), + ); + myCtx.actionContext.telemetry.properties.stage2AnalysisFailed = 'true'; + throw error; } // Check for execution error and return error response if found @@ -305,20 +356,12 @@ export const queryInsightsQueriesRouter = router({ error: analyzed.executionError.errorMessage, }), ); - const errorResponse = createFailedQueryResponse(analyzed, explainResult); - ext.outputChannel.trace(l10n.t('Query Insights Stage 2 completed with execution error')); - return errorResponse; - } - - // Add index-strategy advisories (coverage, cardinality, multikey) - if (explainResult) { - ExplainPlanAnalyzer.addIndexStrategyAdvisories(analyzed, totalCollectionDocs, explainResult, queryFilter); + return transformed; } // Transform to UI format (normal successful execution path) ext.outputChannel.trace(l10n.t('Transforming Stage 2 response to UI format')); - const transformed = transformStage2Response(analyzed, totalCollectionDocs); // Cache the Stage 2 response for Stage 3's static analysis context if (!debugData) { @@ -337,6 +380,7 @@ export const queryInsightsQueriesRouter = router({ myCtx.actionContext.telemetry.properties.isCoveringQuery = transformed.isCoveringQuery ? 'true' : 'false'; myCtx.actionContext.telemetry.properties.fetchOverheadKind = transformed.efficiencyAnalysis.fetchOverheadKind; myCtx.actionContext.telemetry.properties.isSharded = transformed.isSharded ? 'true' : 'false'; + myCtx.actionContext.telemetry.properties.planRefinements = (analyzed.planRefinements ?? []).join(','); myCtx.actionContext.telemetry.measurements.executionTimeMs = transformed.executionTimeMs; myCtx.actionContext.telemetry.measurements.documentsReturned = transformed.documentsReturned; diff --git a/src/webviews/documentdb/collectionView/queryInsightsReducer.ts b/src/webviews/documentdb/collectionView/queryInsightsReducer.ts index 510d9d538..dfd11d98e 100644 --- a/src/webviews/documentdb/collectionView/queryInsightsReducer.ts +++ b/src/webviews/documentdb/collectionView/queryInsightsReducer.ts @@ -369,6 +369,7 @@ export function applyStage3Event( modelFamily: event.modelFamily, usage: event.usage, durationMs: event.durationMs, + modelInputs: event.modelInputs, }, }); diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/GetPerformanceInsightsCard.test.tsx b/src/webviews/documentdb/collectionView/queryInsightsTab/GetPerformanceInsightsCard.test.tsx new file mode 100644 index 000000000..4ec6dfe26 --- /dev/null +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/GetPerformanceInsightsCard.test.tsx @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @vitest-environment jsdom + +import { renderToStaticMarkup } from 'react-dom/server'; // eslint-disable-line import/no-internal-modules -- Public React DOM server entry point. +import { describe, expect, it, vi } from 'vitest'; +import { GetPerformanceInsightsCard } from './components/optimizationCards/custom/GetPerformanceInsightsCard'; + +vi.mock('@vscode/l10n', () => ({ t: (message: string): string => message })); + +describe('GetPerformanceInsightsCard model disclosure', () => { + function render(mayUsePremiumRequests?: boolean, isLoading = false): string { + return renderToStaticMarkup( + Model picker slot} + secondaryLink={Secondary link slot} + />, + ); + } + + it('preserves the existing utility disclosure by default and renders optional slots', () => { + const html = render(); + expect(html).toContain('No additional cost for most GitHub Copilot subscribers.'); + expect(html).toContain('Learn more about the utility model used.'); + expect(html).toContain('Model picker slot'); + expect(html).toContain('Secondary link slot'); + expect(html).not.toContain('The selected model may use premium requests.'); + }); + + it('discloses premium requests without claiming that a non-utility model is free', () => { + const html = render(true); + expect(html).toContain('The selected model may use premium requests.'); + expect(html).not.toContain('No additional cost'); + expect(html).not.toContain('Learn more about the utility model used.'); + }); + + it.each([false, true])('puts the model picker before the action, then one footer line (premium: %s)', (premium) => { + const container = document.createElement('div'); + container.innerHTML = render(premium); + const actions = container.querySelector('.get-performance-insights-card-actions'); + expect([...(actions?.children ?? [])].map((child) => child.textContent)).toEqual([ + 'Model picker slot', + 'Get AI Performance Insights', + ]); + const footer = container.querySelector('.get-performance-insights-card-meta'); + const footerText = footer?.textContent ?? ''; + expect( + footerText.startsWith( + premium + ? 'The selected model may use premium requests.' + : 'No additional cost for most GitHub Copilot subscribers.', + ), + ).toBe(true); + expect(footerText).toContain('AI responses may be inaccurate'); + expect(footerText).toContain('Secondary link slot'); + expect(footer?.lastElementChild?.textContent).toBe('Learn more about AI Performance Insights'); + expect(container.querySelector('.fui-CardHeader')?.textContent).not.toContain('inaccurate'); + expect(container.textContent).not.toMatch(/prompt/i); + }); + + it('keeps the footer visible but hides the picker during loading', () => { + const html = render(false, true); + expect(html).toContain('Cancel'); + expect(html).toContain('No additional cost for most GitHub Copilot subscribers.'); + expect(html).not.toContain('Model picker slot'); + }); +}); diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/ImprovementCard.actions.test.tsx b/src/webviews/documentdb/collectionView/queryInsightsTab/ImprovementCard.actions.test.tsx new file mode 100644 index 000000000..5b3ee7680 --- /dev/null +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/ImprovementCard.actions.test.tsx @@ -0,0 +1,75 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @vitest-environment jsdom +import { SSRProvider } from '@fluentui/react-components'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; // eslint-disable-line import/no-internal-modules -- Public React DOM client entry point. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createImprovementCardConfig } from '../utils/createImprovementCard'; +import { ImprovementCard } from './components/optimizationCards/ImprovementCard'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe('ImprovementCard action capabilities', () => { + let host: HTMLDivElement; + let root: Root; + const config = createImprovementCardConfig( + { + action: 'create', + indexSpec: { status: 1 }, + indexName: 'status_1', + shellCommand: 'db.items.createIndex({status: 1})', + justification: 'Avoid a collection scan.', + priority: 'high', + }, + 0, + { clusterId: 'cluster', databaseName: 'db', collectionName: 'items' }, + ); + + beforeEach(() => { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + }); + + it('preserves the connected primary action by default', async () => { + const action = vi.fn().mockResolvedValue({ success: true }); + await act(async () => + root.render( + + + , + ), + ); + const button = host.querySelector('button'); + expect(button?.textContent).toBe('Create Index…'); + expect(host.textContent).not.toContain('Copy command'); + await act(async () => button?.click()); + expect(action).toHaveBeenCalledWith('createIndex', config.primaryButton?.payload); + }); + + it('shows only Copy command when index actions are disabled', async () => { + const copy = vi.fn(); + const action = vi.fn(); + await act(async () => + root.render( + + + , + ), + ); + const buttons = host.querySelectorAll('button'); + expect(buttons).toHaveLength(1); + expect(buttons[0].textContent).toBe('Copy command'); + await act(async () => buttons[0].click()); + expect(copy).toHaveBeenCalledOnce(); + expect(action).not.toHaveBeenCalled(); + }); +}); diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/ModelPicker.test.tsx b/src/webviews/documentdb/collectionView/queryInsightsTab/ModelPicker.test.tsx new file mode 100644 index 000000000..84598d1bf --- /dev/null +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/ModelPicker.test.tsx @@ -0,0 +1,380 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @vitest-environment jsdom + +import type * as FluentUI from '@fluentui/react-components'; +import { SSRProvider } from '@fluentui/react-components'; +import { act, useId, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; // eslint-disable-line import/no-internal-modules -- Public React DOM client entry point. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type AvailableModelsResult } from '../../../../services/ai/modelSelection'; +import { ModelPicker, type ModelPickerProps } from '../../queryInsightsShared/ModelPicker'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ + getAvailableModels: vi.fn(), + setSelectedModel: vi.fn(), +})); +const client = { + ai: { + getAvailableModels: { query: mocks.getAvailableModels }, + setSelectedModel: { mutate: mocks.setSelectedModel }, + }, +}; +vi.mock('../../../_integration/useTrpcClient', () => ({ useTrpcClient: () => client })); +// jsdom has no layout engine; leave Fluent's floating-popup positioning out of this state/selection test. +vi.mock('@fluentui/react-components', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Dropdown: ({ + children, + disabled, + value, + placeholder, + appearance, + button, + 'aria-labelledby': ariaLabelledBy, + 'aria-label': ariaLabel, + onOpenChange, + onOptionSelect, + }: { + children: ReactNode; + disabled?: boolean; + value: string; + placeholder: string; + appearance?: string; + button?: { children: ReactNode }; + 'aria-label'?: string; + 'aria-labelledby'?: string; + onOpenChange?: (event: React.MouseEvent, data: { open: boolean }) => void; + onOptionSelect: (event: React.MouseEvent, data: { optionValue: string }) => void; + }): ReactNode => { + const listboxId = useId(); + return ( +
+ +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (event.target instanceof HTMLElement) event.target.click(); + } + }} + onClick={(event) => { + const option = (event.target as HTMLElement).closest('[role="option"]'); + if (option?.dataset.value && !disabled) { + onOptionSelect(event, { optionValue: option.dataset.value }); + } + }} + > + {children} +
+
+ ); + }, + Option: ({ children, value }: { children: ReactNode; value: string }): ReactNode => ( + + ), + OptionGroup: ({ children, label }: { children: ReactNode; label: string }): ReactNode => ( +
+ {children} +
+ ), + Popover: ({ children }: { children: ReactNode }): ReactNode => <>{children}, + PopoverTrigger: ({ children }: { children: ReactNode }): ReactNode => <>{children}, + PopoverSurface: ({ + children, + 'aria-label': ariaLabel, + }: { + children: ReactNode; + 'aria-label'?: string; + }): ReactNode => ( +
+ {children} +
+ ), + }; +}); +vi.mock('@vscode/l10n', () => ({ + t: (message: string, ...args: unknown[]): string => + message.replace(/\{(\d+)\}/g, (_match, index: string) => String(args[Number(index)])), +})); + +const available: AvailableModelsResult = { + models: [ + { + id: 'premium', + name: 'Premium model', + family: 'premium', + vendor: 'copilot', + version: '1', + maxInputTokens: 128000, + isUtility: false, + }, + { + id: 'utility', + name: 'Utility model', + family: 'copilot-utility', + vendor: 'copilot', + version: '1', + maxInputTokens: 64000, + isUtility: true, + }, + ], + savedModelId: null, + effectiveModelId: 'premium', + effectiveReason: 'first-available', +}; + +describe('Shared Query Insights ModelPicker', () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.resetAllMocks(); + mocks.getAvailableModels.mockResolvedValue(available); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + }); + + async function renderPicker(onModelChange = vi.fn(), props?: Partial): Promise { + await act(async () => { + root.render( + + + , + ); + }); + } + + function details(): Record { + return Object.fromEntries( + [...host.querySelectorAll('[role="dialog"] dl > div')].map((row) => [ + row.querySelector('dt')?.textContent ?? '', + row.querySelector('dd')?.textContent ?? '', + ]), + ); + } + + it('labels the dropdown and details button and does not show prompt information', async () => { + const onChange = vi.fn(); + await renderPicker(onChange); + const combobox = host.querySelector('[role="combobox"]'); + expect(combobox?.getAttribute('aria-label')).toBe('Copilot model'); + expect(combobox?.textContent).toBe('Premium model'); + expect(host.querySelector('button[aria-label="Model details"]')).not.toBeNull(); + expect(details()).not.toHaveProperty('Prompt'); + expect(host.textContent).not.toMatch(/prompt/i); + expect(onChange).toHaveBeenLastCalledWith(available.models[0]); + }); + + it('orders utility first, refetches the list on open and persists a selection', async () => { + mocks.setSelectedModel.mockResolvedValue({ + ...available, + savedModelId: 'utility', + effectiveModelId: 'utility', + effectiveReason: 'saved', + }); + await renderPicker(); + expect(mocks.getAvailableModels).toHaveBeenCalledTimes(1); + mocks.getAvailableModels.mockResolvedValue({ + ...available, + models: [...available.models, { ...available.models[0], id: 'late', name: 'Late model' }], + }); + await act(async () => host.querySelector('[role="combobox"]')?.click()); + expect(mocks.getAvailableModels).toHaveBeenCalledTimes(2); + expect(host.querySelector('[role="combobox"]')?.disabled).toBe(false); + const options = [...document.querySelectorAll('[role="option"]')]; + expect(options.map((option) => option.textContent)).toEqual(['Utility model', 'Premium model', 'Late model']); + await act(async () => options[0].click()); + expect(mocks.setSelectedModel).toHaveBeenCalledWith({ modelId: 'utility' }); + expect(details().Selection).toBe('Saved choice'); + expect(details().Cost).toBe('No additional cost for most GitHub Copilot subscribers.'); + }); + + it('shows an empty state and disables the picker when no models are available', async () => { + mocks.getAvailableModels.mockResolvedValue({ + models: [], + savedModelId: null, + effectiveModelId: null, + effectiveReason: null, + }); + const onChange = vi.fn(); + await renderPicker(onChange); + expect(host.textContent).toContain('No Copilot models available'); + expect(host.querySelector('[role="combobox"]')?.disabled).toBe(true); + expect(host.querySelector('button[aria-label="Model details"]')).toBeNull(); + expect(host.textContent).toContain('Refresh models'); + expect(onChange).toHaveBeenLastCalledWith(undefined); + }); + + it('groups non-utility models with cost badges and lists the selected model details', async () => { + mocks.getAvailableModels.mockResolvedValue({ + ...available, + models: [ + { + ...available.models[0], + category: 'powerful', + pricing: '1x', + priceCategory: 'high', + inputCost: 0, + outputCost: 6, + cacheCost: 0.25, + longContextInputCost: 4, + supportsToolCalling: true, + supportsImageToText: false, + }, + available.models[1], + { + ...available.models[0], + id: 'light', + name: 'Light model', + category: 'lightweight', + priceCategory: 'low', + }, + { ...available.models[0], id: 'future', name: 'Future model', category: 'new-category' }, + ], + }); + await renderPicker(); + const options = [...host.querySelectorAll('[role="option"]')]; + expect(options.map((element) => element.getAttribute('data-value'))).toEqual([ + 'utility', + 'premium', + 'light', + 'future', + ]); + expect(options.map((element) => element.textContent)).toEqual([ + 'Utility model', + 'Premium modelHigh cost', + 'Light modelLow cost', + 'Future model', + ]); + expect( + [...host.querySelectorAll('[role="group"]')].map((element) => element.getAttribute('aria-label')), + ).toEqual(['Powerful', 'Lightweight', 'Other']); + const facts = details(); + expect(host.querySelector('[role="dialog"]')?.textContent).toContain('Premium model'); + expect(facts.Cost).toBe('High cost'); + expect(facts['Credits per 1M tokens']).toBe('Input 0 · Output 6 · Cache read 0.25Long context: Input 4'); + expect(facts.Capabilities).toBe('Tool calling · No image input'); + expect(facts['Model ID']).toBe('premium'); + expect(facts.Selection).toBe('Default: first available model'); + expect(host.textContent).toContain( + "Thinking level and context size follow this model's settings in the VS Code Chat model picker.", + ); + }); + + it('shows the cost category and never a "Free" label or cost badge for the utility model', async () => { + mocks.getAvailableModels.mockResolvedValue({ + ...available, + models: [{ ...available.models[0], priceCategory: 'very_high' }, available.models[1]], + }); + await renderPicker(); + expect(details().Cost).toBe('Very high cost'); + + mocks.getAvailableModels.mockResolvedValue({ + ...available, + effectiveModelId: 'utility', + models: [available.models[0], { ...available.models[1], pricing: 'Free', priceCategory: 'low' }], + }); + await act(async () => root.unmount()); + root = createRoot(host); + await renderPicker(); + expect(host.querySelector('[data-value="utility"]')?.textContent).toBe('Utility model'); + expect(details().Cost).toBe('No additional cost for most GitHub Copilot subscribers.'); + expect(host.textContent).not.toMatch(/free/i); + }); + + it('says explicitly when a model reports no pricing instead of hiding the row', async () => { + await renderPicker(); + expect(details().Cost).toBe('Not reported for this model'); + expect(details()['Credits per 1M tokens']).toBe('Not reported for this model'); + expect(details()['Context window']).toBe('128,000 tokens'); + }); + + it('uses underline with a decorative model icon only on the closed card trigger', async () => { + await renderPicker(vi.fn(), { appearance: 'underline', showModelIcon: true }); + const trigger = host.querySelector('[role="combobox"]'); + expect(trigger?.dataset.appearance).toBe('underline'); + expect(trigger?.textContent).toBe('Premium model'); + expect(trigger?.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true'); + await act(async () => trigger?.click()); + expect(trigger?.querySelector('svg')).toBeNull(); + expect(host.querySelectorAll('[role="option"]')).toHaveLength(2); + }); + + it('preserves the wizard outline appearance and visible label without a model icon', async () => { + await renderPicker(vi.fn(), { showLabel: true }); + const trigger = host.querySelector('[role="combobox"]'); + expect(trigger?.dataset.appearance).toBe('outline'); + expect(trigger?.querySelector('svg')).toBeNull(); + expect(document.getElementById(trigger?.getAttribute('aria-labelledby') ?? '')?.textContent).toBe( + 'Copilot model', + ); + }); + + it.each(['outline', 'underline'] as const)( + 'keeps Fast model ids and provider labels in the %s list', + async (appearance) => { + const models = [ + { ...available.models[0], id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol', category: 'powerful' }, + { ...available.models[0], id: 'gpt-5.6-sol-fast', name: 'GPT-5.6 Sol Fast', category: undefined }, + available.models[1], + ]; + mocks.getAvailableModels.mockResolvedValue({ ...available, models, effectiveModelId: models[0].id }); + await renderPicker(vi.fn(), { appearance }); + expect( + [...host.querySelectorAll('[role="option"]')].map((element) => [ + element.getAttribute('data-value'), + element.textContent, + ]), + ).toEqual([ + ['utility', 'Utility model'], + ['gpt-5.6-sol', 'GPT-5.6 Sol'], + ['gpt-5.6-sol-fast', 'GPT-5.6 Sol Fast'], + ]); + }, + ); + + it('disambiguates identical provider labels with model ids instead of merging entries', async () => { + mocks.getAvailableModels.mockResolvedValue({ + ...available, + models: [ + { ...available.models[0], id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol' }, + { ...available.models[0], id: 'gpt-5.6-sol-fast', name: 'GPT-5.6 Sol' }, + ], + effectiveModelId: 'gpt-5.6-sol-fast', + }); + await renderPicker(); + expect(host.querySelector('[data-value="gpt-5.6-sol"]')?.textContent).toBe('GPT-5.6 Solgpt-5.6-sol'); + expect(host.querySelector('[data-value="gpt-5.6-sol-fast"]')?.textContent).toBe('GPT-5.6 Solgpt-5.6-sol-fast'); + expect(details()['Model ID']).toBe('gpt-5.6-sol-fast'); + }); +}); diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/QueryInsightsDataSourceContext.tsx b/src/webviews/documentdb/collectionView/queryInsightsTab/QueryInsightsDataSourceContext.tsx new file mode 100644 index 000000000..65409f05b --- /dev/null +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/QueryInsightsDataSourceContext.tsx @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { useConfiguration } from '@microsoft/vscode-ext-webview/react'; +import { createContext, useCallback, useContext, useMemo, type JSX, type ReactNode } from 'react'; +import { type CommandType } from '../../../../commands/llmEnhancedCommands/indexAdvisorCommands'; +import { useTrpcClient } from '../../../_integration/useTrpcClient'; +import { CollectionViewContext, type QueryInsightsState } from '../collectionViewContext'; +import { type CollectionViewWebviewConfigurationType } from '../collectionViewController'; +import { type QueryInsightsStage1Response, type QueryInsightsStage2Response } from '../types/queryInsights'; +import { type QueryInsightsStreamEvent } from '../types/queryInsightsStream'; + +export interface Stage3Callbacks { + onData: (event: QueryInsightsStreamEvent) => void; + onError: (error: unknown) => void; + onComplete: () => void; +} + +export interface QueryInsightsDataSource { + pipeline: QueryInsightsState; + dispatch: (transition: (previous: QueryInsightsState) => QueryInsightsState) => void; + identity: { clusterId?: string; databaseName: string; collectionName: string }; + actionsEnabled: boolean; + isQueryLoading: boolean; + autoStartStage3?: boolean; + commandType?: CommandType; + getStage1: () => Promise; + getStage2: () => Promise; + streamStage3: (requestKey: string, callbacks: Stage3Callbacks, modelId?: string) => { unsubscribe: () => void }; + executeAction: (actionId: string, payload: unknown) => Promise<{ success: boolean; message?: string }>; + explore?: () => Promise; + viewRawExplain?: () => Promise; +} + +export const QueryInsightsDataSourceContext = createContext(undefined); + +function useCollectionDataSource(): QueryInsightsDataSource { + const configuration = useConfiguration(); + const client = useTrpcClient(); + const [state, setState] = useContext(CollectionViewContext); + const dispatch = useCallback( + (transition) => setState((previous) => ({ ...previous, queryInsights: transition(previous.queryInsights) })), + [setState], + ); + return useMemo( + () => ({ + pipeline: state.queryInsights, + dispatch, + identity: configuration, + actionsEnabled: true, + isQueryLoading: state.isLoading, + getStage1: () => client.mongoClusters.collectionView.queryInsights.getQueryInsightsStage1.query(), + getStage2: () => client.mongoClusters.collectionView.queryInsights.getQueryInsightsStage2.query(), + streamStage3: (requestKey, callbacks) => + client.mongoClusters.collectionView.queryInsights.streamStage3.subscribe({ requestKey }, callbacks), + executeAction: (actionId, payload) => + client.mongoClusters.collectionView.queryInsights.executeQueryInsightsAction.mutate({ + actionId, + payload, + }), + explore: async (): Promise => { + const prefill = await client.mongoClusters.collectionView.queryInsights.getExplorePrefill.query(); + await client.queryInsightsStandalone.openExplorer.mutate(prefill); + }, + }), + [state.queryInsights, state.isLoading, dispatch, configuration, client], + ); +} + +export function CollectionQueryInsightsProvider({ children }: { children: ReactNode }): JSX.Element { + const source = useCollectionDataSource(); + return {children}; +} + +export function useQueryInsightsDataSource(): QueryInsightsDataSource { + const source = useContext(QueryInsightsDataSourceContext); + if (!source) { + throw new Error('Query Insights requires a data-source provider.'); + } + return source; +} diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/QueryInsightsTab.scss b/src/webviews/documentdb/collectionView/queryInsightsTab/QueryInsightsTab.scss index 35e6cb591..9d0331891 100644 --- a/src/webviews/documentdb/collectionView/queryInsightsTab/QueryInsightsTab.scss +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/QueryInsightsTab.scss @@ -64,6 +64,16 @@ flex-direction: column; } +// Section title with the "Share" menu on the right. Fixed min-height so the +// title does not shift when the button appears after the AI analysis completes. +.optimizationSectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 24px; +} + .optimizationTitle { margin-bottom: 12px; display: block; diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/QueryInsightsTab.tsx b/src/webviews/documentdb/collectionView/queryInsightsTab/QueryInsightsTab.tsx index 85eda2c11..41229c1dc 100644 --- a/src/webviews/documentdb/collectionView/queryInsightsTab/QueryInsightsTab.tsx +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/QueryInsightsTab.tsx @@ -34,12 +34,16 @@ import { Link, MessageBar, MessageBarBody, Skeleton, SkeletonItem, Text, tokens import { ChatMailRegular, InfoRegular, SparkleRegular, WarningRegular } from '@fluentui/react-icons'; import { CollapseRelaxed, Fade } from '@fluentui/react-motion-components-preview'; import { MetricGrid } from '@microsoft/vscode-ext-webview-fluentui/components'; -import { useConfiguration } from '@microsoft/vscode-ext-webview/react'; import * as l10n from '@vscode/l10n'; -import { useCallback, useContext, useEffect, useMemo, useRef, useState, type JSX } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from 'react'; +import { type AvailableModelInfo } from '../../../../services/ai/modelSelection'; +import { type AIIndexRecommendation } from '../../../../services/ai/types'; import { useTrpcClient } from '../../../_integration/useTrpcClient'; -import { CollectionViewContext } from '../collectionViewContext'; -import { type CollectionViewWebviewConfigurationType } from '../collectionViewController'; +import { getAnalysisProfilePresentation } from '../../queryInsightsShared/analysisProfilePresentation'; +import { ModelPicker } from '../../queryInsightsShared/ModelPicker'; +import { QueryInsightsPreviewBadge } from '../../queryInsightsShared/QueryInsightsPreviewBadge'; +import { ShareMenu, type ShareReportContent } from '../../queryInsightsShared/ShareMenu'; +import { usageByline } from '../../queryInsightsShared/usageByline'; import { applyStage3Event, cancelStage3, @@ -51,9 +55,12 @@ import { startStage1Load, startStage3Load, } from '../queryInsightsReducer'; +import { buildOptimizationMarkdown } from '../utils/buildOptimizationMarkdown'; import { createImprovementCardConfig } from '../utils/createImprovementCard'; +import { createInsightCardConfig } from '../utils/createInsightCard'; import { extractErrorCode } from '../utils/errorCodeExtractor'; import { formatSelectivityForDisplay } from '../utils/formatSelectivityForDisplay'; +import { markdownToEmailHtml } from '../utils/markdownToEmailHtml'; import { CardStack, type CardStackItem } from './components'; import { CountMetric } from './components/metricsRow/CountMetric'; import { TimeMetric } from './components/metricsRow/TimeMetric'; @@ -61,6 +68,7 @@ import { GetPerformanceInsightsCard, ImprovementCard, ImprovementCardShell, + InsightCard, MarkdownCard, MarkdownCardEx, Stage3AnalyzingCard, @@ -68,6 +76,7 @@ import { import { QueryPlanSummary } from './components/queryPlanSummary'; import { GenericCell, PerformanceRatingCell, SummaryCard } from './components/summaryCard'; import './queryInsights.scss'; +import { useQueryInsightsDataSource } from './QueryInsightsDataSourceContext'; import './QueryInsightsTab.scss'; export const QueryInsightsMain = (): JSX.Element => { @@ -88,19 +97,9 @@ export const QueryInsightsMain = (): JSX.Element => { /** * Use the configuration object to access the data passed to the webview at its creation. */ - const configuration = useConfiguration(); - const trpcClient = useTrpcClient(); - const [currentContext, setCurrentContext] = useContext(CollectionViewContext); - const pipeline = currentContext.queryInsights; - - /** Apply a pipeline transition. Wraps the global setState helper. */ - const dispatch = useCallback( - (transition: (prev: typeof pipeline) => typeof pipeline): void => { - setCurrentContext((prev) => ({ ...prev, queryInsights: transition(prev.queryInsights) })); - }, - [setCurrentContext], - ); + const source = useQueryInsightsDataSource(); + const { pipeline, dispatch, identity: configuration, isQueryLoading, getStage1, getStage2, streamStage3 } = source; // ---------- Derived values -------------------------------------------- // @@ -153,6 +152,7 @@ export const QueryInsightsMain = (): JSX.Element => { // None of these need to be in context. const [showErrorCard, setShowErrorCard] = useState(false); + const [selectedModel, setSelectedModel] = useState(); /** * Subscription handle for the in-flight Stage 3 streaming subscription @@ -316,10 +316,9 @@ export const QueryInsightsMain = (): JSX.Element => { // // IMPORTANT: wait for query execution (isLoading=false) before fetching. useEffect(() => { - if (!currentContext.isLoading && pipeline.kind === 'idle') { + if (!isQueryLoading && pipeline.kind === 'idle') { dispatch(startStage1Load); - void trpcClient.mongoClusters.collectionView.queryInsights.getQueryInsightsStage1 - .query() + void getStage1() .then((data) => { dispatch((prev) => stage1Succeeded(prev, data)); }) @@ -331,7 +330,7 @@ export const QueryInsightsMain = (): JSX.Element => { displayStageError(1, errorMessage); }); } - }, [currentContext.isLoading, pipeline.kind, trpcClient, dispatch, displayStageError]); + }, [isQueryLoading, pipeline.kind, getStage1, dispatch, displayStageError]); // ---------- Surface errors as VS Code notifications ------------------- // @@ -376,8 +375,7 @@ export const QueryInsightsMain = (): JSX.Element => { } }; - void trpcClient.mongoClusters.collectionView.queryInsights.getQueryInsightsStage2 - .query() + void getStage2() .then(async (data) => { await ensureMinDuration(); dispatch((prev) => stage2Succeeded(prev, data)); @@ -392,7 +390,7 @@ export const QueryInsightsMain = (): JSX.Element => { stage2InFlightRef.current = false; }); } - }, [pipeline.kind, trpcClient, dispatch]); + }, [pipeline.kind, getStage2, dispatch]); // Debug logging for pipeline state changes useEffect(() => { @@ -445,8 +443,15 @@ export const QueryInsightsMain = (): JSX.Element => { return accessor(stage2Data) ?? unavailableValue; }; - const executionTime = getMetricValue(stage2Data?.executionTimeMs); - const docsReturned = getMetricValue(stage2Data?.documentsReturned); + const analysisProfile = stage2Data?.analysisProfile ?? source.commandType ?? 'find'; + const presentation = getAnalysisProfilePresentation(analysisProfile); + const notApplicable = stage2Data?.notApplicable ?? []; + const notApplicableToCount = l10n.t('Not applicable to count queries'); + const notApplicableShort = l10n.t('Not applicable'); + const executionTime = getMetricValue(notApplicable.includes('executionTime') ? null : stage2Data?.executionTimeMs); + const docsReturned = getMetricValue( + notApplicable.includes('documentsReturned') ? null : stage2Data?.documentsReturned, + ); const keysExamined = getMetricValue(stage2Data?.totalKeysExamined); const docsExamined = getMetricValue(stage2Data?.totalDocsExamined); const lowSelectivityLabel = `below ${(0.1).toLocaleString(navigator.language, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%`; @@ -539,8 +544,8 @@ export const QueryInsightsMain = (): JSX.Element => { // in lock step. stage3SubscriptionRef.current?.unsubscribe(); - const subscription = trpcClient.mongoClusters.collectionView.queryInsights.streamStage3.subscribe( - { requestKey }, + const subscription = streamStage3( + requestKey, { onData(event) { dispatch((prev) => applyStage3Event(prev, requestKey, event)); @@ -561,10 +566,19 @@ export const QueryInsightsMain = (): JSX.Element => { } }, }, + selectedModel?.id, ); stage3SubscriptionRef.current = subscription; }; + const autoStarted = useRef(false); + useEffect(() => { + if (source.autoStartStage3 && pipeline.kind === 's3Idle' && !autoStarted.current) { + autoStarted.current = true; + handleGetAISuggestions(); + } + }, [source.autoStartStage3, pipeline.kind, handleGetAISuggestions]); + const handleCancelAI = (): void => { if (stage3TipsTimerRef.current) { clearTimeout(stage3TipsTimerRef.current); @@ -582,22 +596,45 @@ export const QueryInsightsMain = (): JSX.Element => { const handlePrimaryAction = useCallback( async (actionId: string, payload: unknown): Promise<{ success: boolean; message?: string }> => { - return await trpcClient.mongoClusters.collectionView.queryInsights.executeQueryInsightsAction.mutate({ - actionId, - payload, - }); + return await source.executeAction(actionId, payload); }, - [], + [source.executeAction], + ); + + /** The report as Markdown, the single source for both copy formats. */ + const buildShareMarkdown = useCallback((): string => { + if (pipeline.kind !== 's3Success') throw new Error('The AI analysis has not completed.'); + return buildOptimizationMarkdown({ + databaseName: configuration.databaseName, + collectionName: configuration.collectionName, + staticAnalysis: stage2Data ?? undefined, + analysis: pipeline.streaming.summary?.markdown, + executionErrors: showErrorCard ? stage2Data?.concerns : undefined, + recommendations: pipeline.streaming.recommendations.filter( + (rec): rec is AIIndexRecommendation => rec !== null, + ), + educational: pipeline.streaming.educational?.markdown, + model: pipeline.model, + generatedAt: new Date(), + }); + }, [pipeline, configuration, showErrorCard, stage2Data]); + + /** The same report, rendered with inline styles for email, Teams and Word. */ + const buildShareRichText = useCallback((): ShareReportContent => { + const markdown = buildShareMarkdown(); + return { html: markdownToEmailHtml(markdown), plainText: markdown }; + }, [buildShareMarkdown]); + + const copyShareText = useCallback( + (text: string): Promise => trpcClient.common.copyToClipboard.mutate({ text }), + [trpcClient], ); const handleSecondaryAction = useCallback( async (actionId: string, payload: unknown): Promise<{ success: boolean; message?: string }> => { - return await trpcClient.mongoClusters.collectionView.queryInsights.executeQueryInsightsAction.mutate({ - actionId, - payload, - }); + return await source.executeAction(actionId, payload); }, - [], + [source.executeAction], ); // ---------- Build the animated card list ------------------------------ @@ -668,7 +705,6 @@ export const QueryInsightsMain = (): JSX.Element => { } - showAiDisclaimer={false} content={ stage2Data.concerns.join('\n\n') + '\n\n---\n\n' + @@ -698,8 +734,21 @@ export const QueryInsightsMain = (): JSX.Element => { cards.push({ key, motion: 'fade', component: }); return; } + if (rec.action === 'none') { + cards.push({ + key, + motion: 'fade', + component: ( + trpcClient.common.copyToClipboard.mutate({ text })} + /> + ), + }); + return; + } const config = createImprovementCardConfig(rec, index, { - clusterId: configuration.clusterId, + clusterId: configuration.clusterId ?? '', databaseName: configuration.databaseName, collectionName: configuration.collectionName, }); @@ -709,6 +758,8 @@ export const QueryInsightsMain = (): JSX.Element => { component: ( trpcClient.common.copyToClipboard.mutate({ text: rec.shellCommand })} onPrimaryAction={handlePrimaryAction} onSecondaryAction={handleSecondaryAction} /> @@ -771,6 +822,7 @@ export const QueryInsightsMain = (): JSX.Element => { showErrorCard, stage2Data, configuration, + source.actionsEnabled, handlePrimaryAction, handleSecondaryAction, ]); @@ -784,14 +836,18 @@ export const QueryInsightsMain = (): JSX.Element => { {/* Metrics Row */} { {/* Optimization Opportunities */}
- {/* Section title */} - - {l10n.t('Optimization Opportunities')} - + {/* Section title, with the Share menu once the AI analysis is complete */} +
+ + {l10n.t('Optimization Opportunities')} + + {isStage3Success && ( + + )} +
{/* Platform-specific error card for RU clusters */} {stage1ErrorCode === 'QUERY_INSIGHTS_PLATFORM_NOT_SUPPORTED_RU' && ( } - showAiDisclaimer={false} content={l10n.t( 'This feature is currently not enabled for RU-based accounts. We recognize that these accounts have unique performance characteristics, and standard optimization patterns from other DocumentDB environments may not be applicable.\n\n' + 'To ensure our recommendations are accurate and genuinely helpful, a specific analysis tailored to the Request Unit (RU) model is required. For this reason, we have disabled the feature for this account type.\n\n' + @@ -978,8 +1042,17 @@ export const QueryInsightsMain = (): JSX.Element => { /> ) : ( + } className="cardSpacing" bodyText={ + !notApplicable.includes('performanceRating') && stage2Data?.efficiencyAnalysis.performanceRating.score === 'excellent' ? l10n.t( 'Your query is performing well. You can still use the AI-powered analysis to get a detailed explanation of the query execution, review the indexing, and explore if further optimizations are possible.', @@ -995,9 +1068,18 @@ export const QueryInsightsMain = (): JSX.Element => { // it false. isLoading={false} enabled={ - pipeline.kind === 's3Idle' || - pipeline.kind === 's3Error' || - pipeline.kind === 's3Cancelled' + !!selectedModel && + (pipeline.kind === 's3Idle' || + pipeline.kind === 's3Error' || + pipeline.kind === 's3Cancelled') + } + mayUsePremiumRequests={!selectedModel?.isUtility} + secondaryLink={ + source.explore && ( + void source.explore?.()}> + {l10n.t('Explore with your own input…')} + + ) } errorMessage={pipeline.kind === 's3Error' ? pipeline.message : undefined} onGetInsights={handleGetAISuggestions} @@ -1024,12 +1106,9 @@ export const QueryInsightsMain = (): JSX.Element => { 0} /> {/* Post-response "Powered by" byline. - Mirrors the (i) + cost-neutral disclosure shown in the pre-invocation - card. Token usage measurements are intentionally NOT rendered here: - they live in the trace output channel and in telemetry only. Cost - (credits) is not surfaced because the stable VS Code Language Model - API does not expose pricing data; the extension stays on stable APIs - and avoids the proposed `languageModelPricing` API by design. + Mirrors the (i) + model-dependent disclosure shown in the pre-invocation + card. Known usage and billed credits come from the completed run, + not from the currently selected model's pricing metadata. Wrapped in `Fade` so it slides in over the same window the GetPerformanceInsightsCard above is collapsing out — without the @@ -1052,18 +1131,26 @@ export const QueryInsightsMain = (): JSX.Element => {
@@ -1099,12 +1204,25 @@ export const QueryInsightsMain = (): JSX.Element => { {/* Right Column: Efficiency Analysis, Query Plan, Quick Actions */}
{/* Query Efficiency Analysis */} - + } + description={ + analysisProfile === 'aggregate' && stage2Data?.analysisNotes?.length ? ( + {stage2Data.analysisNotes.join(' ')} + ) : undefined + } + > { + if (analysisProfile === 'count') return notApplicableToCount; const selectivity = stage2Data?.efficiencyAnalysis.selectivity; if (selectivity === null || selectivity === undefined) { return l10n.t( @@ -1152,9 +1270,14 @@ export const QueryInsightsMain = (): JSX.Element => { /> stage2.efficiencyAnalysis.fetchOverhead)} + value={ + analysisProfile === 'count' + ? notApplicableShort + : getCellValue((stage2) => stage2.efficiencyAnalysis.fetchOverhead) + } loadingPlaceholder="skeleton" tooltipExplanation={(() => { + if (analysisProfile === 'count') return notApplicableToCount; const kind = stage2Data?.efficiencyAnalysis.fetchOverheadKind ?? 'directFetch'; if (kind === 'covered') { return l10n.t( @@ -1181,11 +1304,23 @@ export const QueryInsightsMain = (): JSX.Element => { /> - stage2.efficiencyAnalysis.hasInMemorySort ? l10n.t('Yes') : l10n.t('No'), - )} + value={ + analysisProfile === 'count' + ? notApplicableShort + : notApplicable.includes('inMemorySort') + ? l10n.t('Not rated for pipeline $sort') + : getCellValue((stage2) => + stage2.efficiencyAnalysis.hasInMemorySort ? l10n.t('Yes') : l10n.t('No'), + ) + } loadingPlaceholder="skeleton" tooltipExplanation={(() => { + if (analysisProfile === 'count') return notApplicableToCount; + if (notApplicable.includes('inMemorySort')) { + return l10n.t( + 'A later pipeline $sort is present. The initial cursor plan cannot determine its memory or disk usage.', + ); + } const hasSort = stage2Data?.efficiencyAnalysis.hasInMemorySort ?? false; if (hasSort) { return l10n.t( @@ -1200,14 +1335,14 @@ export const QueryInsightsMain = (): JSX.Element => { { {/* Query Plan Summary */}
diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/analysisProfilePresentation.test.ts b/src/webviews/documentdb/collectionView/queryInsightsTab/analysisProfilePresentation.test.ts new file mode 100644 index 000000000..045750cb1 --- /dev/null +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/analysisProfilePresentation.test.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { getAnalysisProfilePresentation } from '../../queryInsightsShared/analysisProfilePresentation'; + +describe('Query Insights result metric applicability', () => { + it('keeps find labels and explanations unchanged', () => { + expect(getAnalysisProfilePresentation('find')).toMatchObject({ + resultLabel: 'Documents Returned', + resultTooltip: 'Number of documents returned by the query', + }); + expect(getAnalysisProfilePresentation('find').resultSummary('12')).toBe('12 returned'); + }); + + it('labels aggregate cursor throughput rather than claiming final pipeline output', () => { + const presentation = getAnalysisProfilePresentation('aggregate'); + expect(presentation.resultLabel).toBe('Documents passed to pipeline'); + expect(presentation.resultTooltip).toContain('not the final pipeline output'); + expect(presentation.resultSummary('12')).toBe('12 passed to pipeline'); + }); + + it('labels count results and makes the nCounted source explicit', () => { + const presentation = getAnalysisProfilePresentation('count'); + expect(presentation.resultLabel).toBe('Documents counted'); + expect(presentation.resultTooltip).toContain('nCounted'); + expect(presentation.resultSummary('12')).toBe('12 counted'); + expect(presentation.resultSummary('N/A')).toBe('N/A counted'); + }); +}); diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/ImprovementCard.tsx b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/ImprovementCard.tsx index 09e00e8d6..24f8c7d45 100644 --- a/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/ImprovementCard.tsx +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/ImprovementCard.tsx @@ -17,10 +17,9 @@ import { tokens, } from '@fluentui/react-components'; import { ArrowTrendingSparkleRegular } from '@fluentui/react-icons'; -// TODO: Copy content feature will be added in the next release -// import { CopyRegular } from '@fluentui/react-icons'; import * as l10n from '@vscode/l10n'; import { useState } from 'react'; +import { CopyButton } from '../../../../queryInsightsShared/CopyButton'; import { type ImprovementCard as ImprovementCardConfig } from '../../../types/queryInsights'; import './baseOptimizationCard.scss'; @@ -29,11 +28,13 @@ export interface ImprovementCardProps { * Configuration for the improvement card */ config: ImprovementCardConfig; + actionsEnabled?: boolean; /** - * Optional callback when the copy button is clicked + * Copies the card's command. Shown as a secondary action next to the primary one, or alone + * when actions are disabled. A resolved `false` or a rejection is reported as a failed copy. */ - onCopy?: () => void; + onCopy?: () => unknown; /** * Callback when the primary button is clicked @@ -80,10 +81,10 @@ const priorityColors: Record<'high' | 'medium' | 'low', 'danger' | 'warning' | ' * **Important**: The component applies `marginBottom: '16px'` by default for proper spacing in animated lists. * The margin is on the Card itself to ensure borders and shadows render immediately during collapse animations. */ -// TODO: Copy content feature will be added in the next release - _onCopy parameter will be used then export function ImprovementCard({ config, - onCopy: _onCopy, + actionsEnabled = true, + onCopy, onPrimaryAction, onSecondaryAction, ref, @@ -184,11 +185,6 @@ export function ImprovementCard({ } - action={ - - {l10n.t('AI responses may be inaccurate')} - - } />
{/* Description */} @@ -279,7 +275,7 @@ export function ImprovementCard({ {/* Action Buttons */}
- {config.primaryButton && ( + {actionsEnabled && config.primaryButton && (
diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/ImprovementCardShell.tsx b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/ImprovementCardShell.tsx index 157592f29..8ce8009df 100644 --- a/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/ImprovementCardShell.tsx +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/ImprovementCardShell.tsx @@ -78,19 +78,6 @@ export function ImprovementCardShell({ mode = 'loading', ref }: ImprovementCardS } - // Match {@link ImprovementCard}: the AI disclaimer is - // visible on every state the recommendation slot can - // be in (loading shell, filled card, empty state). - // Showing it only after content arrives created a - // perceived inconsistency — the disclaimer would - // appear at the same moment the user starts reading, - // i.e. exactly the wrong moment to ask them to be - // sceptical. - action={ - - {l10n.t('AI responses may be inaccurate')} - - } /> {isEmpty ? ( diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/InsightCard.tsx b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/InsightCard.tsx new file mode 100644 index 000000000..df57f7abe --- /dev/null +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/InsightCard.tsx @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Badge, Card, CardHeader, Label, Text, tokens } from '@fluentui/react-components'; +import { + BeakerRegular, + CodeRegular, + DatabaseRegular, + LightbulbRegular, + PersonSupportRegular, + WrenchRegular, +} from '@fluentui/react-icons'; +import * as l10n from '@vscode/l10n'; +import { useId } from 'react'; +import { CopyButton } from '../../../../queryInsightsShared/CopyButton'; +import { type InsightCard as InsightCardConfig, type InsightKind } from '../../../types/queryInsights'; +import './baseOptimizationCard.scss'; + +export interface InsightCardProps { + config: InsightCardConfig; + /** Copies the card's command. A resolved `false` or a rejection is reported as a failed copy. */ + onCopy?: (command: string) => unknown; + ref?: React.Ref; +} + +const priorityColors: Record<'high' | 'medium' | 'low', 'danger' | 'warning' | 'informative'> = { + high: 'danger', + medium: 'warning', + low: 'informative', +}; + +const kindIcons: Record> = { + 'query-change': CodeRegular, + verify: BeakerRegular, + maintenance: WrenchRegular, + 'data-model': DatabaseRegular, + support: PersonSupportRegular, + other: LightbulbRegular, +}; + +/** + * Card for a recommendation that is not an index change (`action: "none"`): a query change, + * a verification step, maintenance, a data-model change or a support escalation. + * It is advisory only: there is no execute button, and an optional command can be copied. + * Same outer layout and spacing as {@link ImprovementCard} so both can share a card stack. + */ +export function InsightCard({ config, onCopy, ref }: InsightCardProps) { + const commandLabelId = useId(); + const Icon = kindIcons[config.kind]; + const priorityBadgeText = { + high: l10n.t('HIGH PRIORITY'), + medium: l10n.t('MEDIUM PRIORITY'), + low: l10n.t('LOW PRIORITY'), + }[config.priority]; + + return ( + +
+
+ +
+ ); +} diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/MarkdownCard.tsx b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/MarkdownCard.tsx index 16cd7655e..5f0bf585f 100644 --- a/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/MarkdownCard.tsx +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/MarkdownCard.tsx @@ -123,12 +123,6 @@ interface MarkdownCardProps { */ onCopy?: () => void; - /** - * Whether to show the AI disclaimer. Default: true - * Set to false for non-AI generated content (e.g., error messages) - */ - showAiDisclaimer?: boolean; - /** * When `true`, render a {@link StreamingInlineProgress} (spinner + label) * below the rendered markdown to make it visually obvious that the @@ -178,7 +172,6 @@ export function MarkdownCard({ content, icon, onCopy: _onCopy, - showAiDisclaimer = true, inFlight = false, inFlightLabel, ref, @@ -198,13 +191,6 @@ export function MarkdownCard({ {title}
} - action={ - showAiDisclaimer ? ( - - {l10n.t('AI responses may be inaccurate')} - - ) : undefined - } />
{content} diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/MarkdownCardEx.tsx b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/MarkdownCardEx.tsx index 21faf70ad..30c576469 100644 --- a/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/MarkdownCardEx.tsx +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/MarkdownCardEx.tsx @@ -5,7 +5,6 @@ import { Card, CardHeader, makeStyles, Text, tokens } from '@fluentui/react-components'; import { SparkleRegular } from '@fluentui/react-icons'; -import * as l10n from '@vscode/l10n'; import { type JSX } from 'react'; import ReactMarkdown from 'react-markdown'; import './baseOptimizationCard.scss'; @@ -113,12 +112,6 @@ interface MarkdownCardExProps { */ onCopy?: () => void; - /** - * Whether to show the AI disclaimer. Default: true - * Set to false for non-AI generated content (e.g., error messages) - */ - showAiDisclaimer?: boolean; - /** * Optional children to render between the title and content */ @@ -155,15 +148,7 @@ interface MarkdownCardExProps { * The margin is on the Card itself to ensure borders and shadows render immediately during collapse animations. */ // TODO: Copy content feature will be added in the next release - _onCopy parameter will be used then -export function MarkdownCardEx({ - title, - content, - icon, - onCopy: _onCopy, - showAiDisclaimer = true, - children, - ref, -}: MarkdownCardExProps) { +export function MarkdownCardEx({ title, content, icon, onCopy: _onCopy, children, ref }: MarkdownCardExProps) { const styles = useStyles(); return ( @@ -179,13 +164,6 @@ export function MarkdownCardEx({ {title} } - action={ - showAiDisclaimer ? ( - - {l10n.t('AI responses may be inaccurate')} - - ) : undefined - } style={{ marginBottom: '16px' }} /> {/* Custom children content between header and main content */} diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.scss b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.scss index a66f35ec6..3e9286501 100644 --- a/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.scss +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.scss @@ -9,7 +9,31 @@ &-actions { display: flex; - gap: 8px; + align-items: center; + gap: var(--spacingHorizontalM); flex-wrap: wrap; } + + // Keeps the picker's own error text under the dropdown instead of beside the button. + &-model { + min-width: 0; + max-width: 100%; + } + + &-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + column-gap: var(--spacingHorizontalS); + row-gap: var(--spacingVerticalXXS); + margin-top: var(--spacingVerticalM); + color: var(--colorNeutralForeground3); + font-size: var(--fontSizeBase200); + line-height: var(--lineHeightBase200); + + .fui-Link { + font-size: inherit; + line-height: inherit; + } + } } diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.tsx b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.tsx index c1b2d4f8b..1e23d8b9f 100644 --- a/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.tsx +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.tsx @@ -14,9 +14,10 @@ import { Text, tokens, } from '@fluentui/react-components'; -import { InfoRegular, SparkleRegular } from '@fluentui/react-icons'; +import { SparkleRegular } from '@fluentui/react-icons'; import * as l10n from '@vscode/l10n'; import type * as React from 'react'; +import { Fragment } from 'react'; import { Announcer } from '../../../../../../components/accessibility'; import '../baseOptimizationCard.scss'; import './GetPerformanceInsightsCard.scss'; @@ -42,6 +43,11 @@ export interface GetPerformanceInsightsCardProps { */ enabled?: boolean; + modelPicker?: React.ReactNode; + /** Optional link to the Query Insights Explorer, shown in the footer line. */ + secondaryLink?: React.ReactNode; + mayUsePremiumRequests?: boolean; + /** * Optional error message. If provided, shows error state with retry button */ @@ -99,6 +105,9 @@ export function GetPerformanceInsightsCard({ recommendation, isLoading, enabled = true, + modelPicker, + secondaryLink, + mayUsePremiumRequests = false, errorMessage, onGetInsights, onLearnMore, @@ -124,15 +133,8 @@ export function GetPerformanceInsightsCard({
- - {l10n.t('AI Performance Insights')} - -
- } - action={ - - {l10n.t('AI responses may be inaccurate')} + + {l10n.t('AI Performance Insights')} } style={{ marginBottom: '8px' }} @@ -172,7 +174,9 @@ export function GetPerformanceInsightsCard({
) : ( + // Configuration first, then the action (as in the VS Code Chat input).
+ {modelPicker &&
{modelPicker}
} -
)} - {/* Cost-neutral disclosure row. - Always rendered so users see the disclosure both before clicking and during loading. - The "Learn more" link wires to `onLearnMoreUtilityModel` (the utility-model - cost-disclosure page), distinct from the general feature `onLearnMore` used by - the button above. Keeping the two URLs separate lets the parent panel update - each independently. */} -
-
diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/index.ts b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/index.ts index 8394f2217..09e6d5317 100644 --- a/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/index.ts +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/components/optimizationCards/index.ts @@ -6,5 +6,6 @@ export * from './custom'; export * from './ImprovementCard'; export * from './ImprovementCardShell'; +export * from './InsightCard'; export * from './MarkdownCard'; export * from './MarkdownCardEx'; diff --git a/src/webviews/documentdb/collectionView/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.tsx b/src/webviews/documentdb/collectionView/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.tsx index d1222ed6c..d4086e649 100644 --- a/src/webviews/documentdb/collectionView/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.tsx +++ b/src/webviews/documentdb/collectionView/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.tsx @@ -24,6 +24,8 @@ import { type QueryInsightsStage1Response, type QueryInsightsStage2Response, } from '../../../../../documentdb/collectionView/types/queryInsights'; +import { QueryInsightsPreviewBadge } from '../../../../queryInsightsShared/QueryInsightsPreviewBadge'; +import { getAnalysisProfilePresentation } from '../../../../queryInsightsShared/analysisProfilePresentation'; import '../../queryInsights.scss'; import './QueryPlanSummary.scss'; import { StageDetailCard, type StageType } from './StageDetailCard'; @@ -34,6 +36,10 @@ interface QueryPlanSummaryProps { stage1Loading: boolean; stage2Loading: boolean; hasError: boolean; + /** True when Stage 2 failed after the explain ran, so the raw plan can still be opened. */ + rawExplainAvailable?: boolean; + preview?: boolean; + onViewRawExplain?: () => Promise; } export const QueryPlanSummary: React.FC = ({ @@ -42,8 +48,22 @@ export const QueryPlanSummary: React.FC = ({ stage1Loading, stage2Loading, hasError, + rawExplainAvailable = false, + preview = false, + onViewRawExplain, }) => { const trpcClient = useTrpcClient(); + const profile = stage2Data?.analysisProfile ?? 'find'; + const resultSummary = stage2Data + ? getAnalysisProfilePresentation(profile).resultSummary( + stage2Data.notApplicable?.includes('documentsReturned') + ? l10n.t('N/A') + : stage2Data.documentsReturned.toLocaleString(), + ) + : ''; + const timeSummary = stage2Data?.notApplicable?.includes('executionTime') + ? l10n.t('Time unavailable') + : `${stage2Data?.executionTimeMs.toFixed(2) ?? ''}ms`; const handleStageDetailsToggle = (isExpanded: boolean) => { trpcClient.common.reportEvent @@ -61,7 +81,8 @@ export const QueryPlanSummary: React.FC = ({ const handleViewRawExplain = async () => { try { - await trpcClient.mongoClusters.collectionView.viewRawExplainOutput.mutate(); + if (onViewRawExplain) await onViewRawExplain(); + else await trpcClient.mongoClusters.collectionView.viewRawExplainOutput.mutate(); } catch (error) { void trpcClient.common.displayErrorMessage.mutate({ message: l10n.t('Failed to open raw execution stats'), @@ -80,8 +101,14 @@ export const QueryPlanSummary: React.FC = ({ > {l10n.t('Query Plan Summary')} + {preview && ( + <> + {' '} + + + )} - {stage2Data && ( + {(stage2Data || rawExplainAvailable) && ( + {feedback.announcer} + + ); +} diff --git a/src/webviews/documentdb/queryInsightsShared/ModelPicker.scss b/src/webviews/documentdb/queryInsightsShared/ModelPicker.scss new file mode 100644 index 000000000..9c9f1fc4c --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/ModelPicker.scss @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.query-insights-model-picker { + display: flex; + flex-direction: column; + gap: var(--spacingVerticalXS); + min-width: 0; + + &-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--spacingHorizontalXS); + min-width: 0; + + > .fui-Dropdown { + max-width: 100%; + } + } +} + +.query-insights-model-muted { + color: var(--colorNeutralForeground3); +} + +// Fluent's default listbox cap is 80vh; this cap fits on one side of the trigger, so positioning can +// place the list fully below or above it. Two classes so these win over Fluent's Griffel rules. +.fui-Listbox.query-insights-model-listbox { + min-width: 240px; + max-height: min(360px, 45vh); +} + +.query-insights-model-trigger { + display: inline-flex; + align-items: center; + gap: var(--spacingHorizontalS); +} + +// Option content: model name, cost badge pushed to the end of the row. +.query-insights-model-option { + display: flex; + flex: 1; + align-items: center; + justify-content: space-between; + gap: var(--spacingHorizontalL); + min-width: 0; + + > .fui-Badge { + flex-shrink: 0; + white-space: nowrap; + } +} + +// The surface sizes to its content; the cap keeps long model IDs and credit lines wrapping inside it +// (the popover autoSizes only its height, so this width cap holds). +.fui-PopoverSurface.query-insights-model-details-surface { + max-width: min(360px, calc(100vw - 2 * var(--spacingHorizontalL))); + box-sizing: border-box; +} + +.query-insights-model-details { + display: flex; + flex-direction: column; + gap: var(--spacingVerticalS); +} + +.query-insights-model-fact-list { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: var(--spacingVerticalXS) var(--spacingHorizontalM); + align-items: baseline; + margin: 0; + + > div { + display: contents; + } + + dt { + color: var(--colorNeutralForeground3); + } + + dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; + } +} + +.query-insights-model-nowrap { + white-space: nowrap; +} + +.query-insights-model-identity { + overflow-wrap: anywhere; +} diff --git a/src/webviews/documentdb/queryInsightsShared/ModelPicker.tsx b/src/webviews/documentdb/queryInsightsShared/ModelPicker.tsx new file mode 100644 index 000000000..17578d3c6 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/ModelPicker.tsx @@ -0,0 +1,457 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + Badge, + Button, + Dropdown, + Label, + Option, + OptionGroup, + Popover, + PopoverSurface, + PopoverTrigger, + Text, + useId, +} from '@fluentui/react-components'; +import { BotRegular, InfoRegular } from '@fluentui/react-icons'; +import * as l10n from '@vscode/l10n'; +import { Fragment, useEffect, useRef, useState, type JSX, type ReactNode } from 'react'; +import { type AvailableModelInfo, type AvailableModelsResult } from '../../../services/ai/modelSelection'; +import { useTrpcClient } from '../../_integration/useTrpcClient'; +import { Announcer } from '../../components/accessibility'; +import './ModelPicker.scss'; + +export interface ModelPickerProps { + /** Control size; the Collection View card uses `small`, the wizard matches its other fields. */ + size?: 'small' | 'medium'; + /** Show a visible "Copilot model" label above the dropdown (wizard form layout). */ + showLabel?: boolean; + appearance?: 'outline' | 'underline'; + showModelIcon?: boolean; + disabled?: boolean; + /** Undefined while loading/saving, or when no model is available. */ + onModelChange?: (model: AvailableModelInfo | undefined) => void; +} + +const knownCategories = ['powerful', 'versatile', 'lightweight']; + +function categoryLabel(category: string | undefined): string { + switch (category) { + case 'powerful': + return l10n.t('Powerful'); + case 'versatile': + return l10n.t('Versatile'); + case 'lightweight': + return l10n.t('Lightweight'); + default: + return l10n.t('Other'); + } +} + +function formatCredits(value: number): string { + return new Intl.NumberFormat(navigator.language, { maximumFractionDigits: 2 }).format(value); +} + +function ModelCostBadge({ category }: { category: string | undefined }): JSX.Element | null { + if (!category) return null; + // VS Code modelPickerPresentation.ts: two tints, not a calculated per-token price. + // CAPI model_picker_price_category is read by Copilot's platform/endpoint/node/chatEndpoint.ts. + let label: string; + switch (category) { + case 'low': + label = l10n.t('Low cost'); + break; + case 'medium': + label = l10n.t('Medium cost'); + break; + case 'high': + label = l10n.t('High cost'); + break; + case 'very_high': + label = l10n.t('Very high cost'); + break; + default: + label = l10n.t('{0} cost', category.charAt(0).toUpperCase() + category.slice(1)); + } + return ( + + {label} + + ); +} + +type CostKey = + | 'inputCost' + | 'outputCost' + | 'cacheCost' + | 'cacheWriteCost' + | 'longContextInputCost' + | 'longContextOutputCost' + | 'longContextCacheCost' + | 'longContextCacheWriteCost'; + +/** One line per context tier, each a list of parts such as "Input 125", "Output 1,000". */ +function creditLines(model: AvailableModelInfo): { key: string; label?: string; parts: string[] }[] { + const kinds = [l10n.t('Input'), l10n.t('Output'), l10n.t('Cache read'), l10n.t('Cache write')]; + const tiers: { key: string; label?: string; keys: readonly CostKey[] }[] = [ + { key: 'standard', keys: ['inputCost', 'outputCost', 'cacheCost', 'cacheWriteCost'] }, + { + key: 'long-context', + label: l10n.t('Long context'), + keys: [ + 'longContextInputCost', + 'longContextOutputCost', + 'longContextCacheCost', + 'longContextCacheWriteCost', + ], + }, + ]; + return tiers.flatMap((tier) => { + const parts = tier.keys.flatMap((key, index) => { + const value = model[key]; + return value !== undefined ? [`${kinds[index]} ${formatCredits(value)}`] : []; + }); + if (parts.length === 0) return []; + return [{ key: tier.key, label: tier.label, parts }]; + }); +} + +interface ModelFact { + readonly key: string; + readonly label: string; + readonly value: ReactNode; +} + +/** Every fact is always listed, so a missing value reads as "not reported", never as a hidden row. */ +function modelFacts(model: AvailableModelInfo, selectionReason: string): ModelFact[] { + const notReported = ( + + {l10n.t('Not reported for this model')} + + ); + const credits = creditLines(model); + const capabilities: string[] = []; + if (model.supportsToolCalling !== undefined) { + capabilities.push(model.supportsToolCalling ? l10n.t('Tool calling') : l10n.t('No tool calling')); + } + if (model.supportsImageToText !== undefined) { + capabilities.push(model.supportsImageToText ? l10n.t('Image input') : l10n.t('No image input')); + } + let cost: ReactNode = notReported; + if (model.isUtility) { + cost = {l10n.t('No additional cost for most GitHub Copilot subscribers.')}; + } else if (model.priceCategory) { + cost = ; + } + const facts: ModelFact[] = [ + { key: 'cost', label: l10n.t('Cost'), value: cost }, + { + key: 'credits', + label: l10n.t('Credits per 1M tokens'), + value: + credits.length > 0 + ? credits.map((line) => ( + // Each part stays on one line, so wrapping happens only between parts. + + {line.label && <>{l10n.t('{0}:', line.label)} } + {line.parts.map((part, index) => ( + + {index > 0 && ' · '} + {part} + + ))} + + )) + : notReported, + }, + { + key: 'context', + label: l10n.t('Context window'), + value: + model.maxInputTokens > 0 ? ( + + {l10n.t('{0} tokens', model.maxInputTokens.toLocaleString(navigator.language))} + + ) : ( + notReported + ), + }, + { + key: 'capabilities', + label: l10n.t('Capabilities'), + value: capabilities.length > 0 ? {capabilities.join(' · ')} : notReported, + }, + { + key: 'id', + label: l10n.t('Model ID'), + value: ( + + {model.id} + + ), + }, + { key: 'selection', label: l10n.t('Selection'), value: {selectionReason} }, + ]; + return facts; +} + +function ModelDetails({ model, facts }: { model: AvailableModelInfo; facts: readonly ModelFact[] }): JSX.Element { + return ( +
+ {model.name} +
+ {facts.map((fact) => ( +
+
+ {fact.label} +
+
{fact.value}
+
+ ))} +
+ + {l10n.t( + "Thinking level and context size follow this model's settings in the VS Code Chat model picker.", + )} + +
+ ); +} + +/** + * Copilot model selection shared by the Collection View card and the Query Insights Explorer: + * a Fluent Dropdown grouped by model category, plus an info button whose popover holds the details. + */ +export function ModelPicker({ + size = 'small', + showLabel = false, + appearance = 'outline', + showModelIcon = false, + disabled = false, + onModelChange, +}: ModelPickerProps): JSX.Element { + const client = useTrpcClient(); + const labelId = useId('query-insights-model-label'); + const [available, setAvailable] = useState(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(); + const [refreshKey, setRefreshKey] = useState(0); + const [open, setOpen] = useState(false); + const mounted = useRef(false); + const requestId = useRef(0); + const refreshRef = useRef<() => Promise>(async () => undefined); + const selected = available?.models.find((model) => model.id === available.effectiveModelId); + + useEffect(() => { + onModelChange?.(loading || saving ? undefined : selected); + }, [loading, saving, selected, onModelChange]); + + useEffect(() => { + mounted.current = true; + const controller = new AbortController(); + let hasLoaded = false; + // Only the first load shows the loading state; later refreshes (focus, dropdown open) are silent + // so an open listbox is not disabled and closed while Copilot registers more models. + const refresh = async (): Promise => { + const currentRequest = ++requestId.current; + if (!hasLoaded) setLoading(true); + try { + const models = await client.ai.getAvailableModels.query(undefined, { signal: controller.signal }); + if (!controller.signal.aborted && currentRequest === requestId.current) { + hasLoaded = true; + setAvailable(models); + setError(undefined); + } + } catch (cause) { + if (!controller.signal.aborted && currentRequest === requestId.current) { + setError( + l10n.t( + 'Failed to load model settings: {0}', + cause instanceof Error ? cause.message : String(cause), + ), + ); + } + } finally { + if (!controller.signal.aborted && currentRequest === requestId.current) { + setLoading(false); + } + } + }; + refreshRef.current = refresh; + const onFocus = (): void => { + void refresh(); + }; + void refresh(); + window.addEventListener('focus', onFocus); + return () => { + mounted.current = false; + controller.abort(); + window.removeEventListener('focus', onFocus); + }; + }, [client, refreshKey]); + + const selectModel = async (modelId: string): Promise => { + // Drop any in-flight refresh so it cannot overwrite the saved choice with older data. + requestId.current++; + setSaving(true); + setError(undefined); + try { + const result = await client.ai.setSelectedModel.mutate({ modelId }); + if (mounted.current) { + setAvailable(result); + } + } catch (cause) { + if (mounted.current) { + setError( + l10n.t('Failed to save model choice: {0}', cause instanceof Error ? cause.message : String(cause)), + ); + } + } finally { + if (mounted.current) { + setSaving(false); + } + } + }; + + const utility = available?.models.filter((model) => model.isUtility) ?? []; + const others = available?.models.filter((model) => !model.isUtility) ?? []; + const hasModels = utility.length + others.length > 0; + let selectionReason: string; + switch (available?.effectiveReason) { + case 'saved': + selectionReason = l10n.t('Saved choice'); + break; + case 'first-powerful': + selectionReason = l10n.t('Default: first Powerful model'); + break; + default: + selectionReason = l10n.t('Default: first available model'); + } + + const option = (model: AvailableModelInfo): JSX.Element => { + const duplicateName = available?.models.some((other) => other.id !== model.id && other.name === model.name); + return ( + + ); + }; + const groupedOthers = others.some((model) => model.category) + ? [...knownCategories, 'other'].map((category) => { + const group = others.filter( + (model) => (knownCategories.includes(model.category ?? '') ? model.category : 'other') === category, + ); + return group.length > 0 ? ( + + {group.map(option)} + + ) : null; + }) + : others.map(option); + + return ( + <> +
+ {showLabel && ( + + )} +
+ + {!open && + {selected && ( + + + + )} +
+
+ {error && {error}} + + + ); +} diff --git a/src/webviews/documentdb/queryInsightsShared/QueryInsightsPreviewBadge.tsx b/src/webviews/documentdb/queryInsightsShared/QueryInsightsPreviewBadge.tsx new file mode 100644 index 000000000..e0f60505b --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/QueryInsightsPreviewBadge.tsx @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Badge } from '@fluentui/react-components'; +import * as l10n from '@vscode/l10n'; +import { type JSX } from 'react'; + +export function QueryInsightsPreviewBadge({ feature = false }: { feature?: boolean }): JSX.Element { + return ( + + {feature ? l10n.t('Feature preview') : l10n.t('Preview · tuned for find queries')} + + ); +} diff --git a/src/webviews/documentdb/queryInsightsShared/ShareMenu.test.tsx b/src/webviews/documentdb/queryInsightsShared/ShareMenu.test.tsx new file mode 100644 index 000000000..c22217e44 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/ShareMenu.test.tsx @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @vitest-environment jsdom +import type * as FluentUI from '@fluentui/react-components'; +import { SSRProvider } from '@fluentui/react-components'; +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; // eslint-disable-line import/no-internal-modules -- Public React DOM client entry point. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ShareMenu } from './ShareMenu'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +// jsdom has no layout engine and Fluent's floating-popup positioning never settles there: render +// the menu surface inline. Opening and keyboard behavior are covered by the L2 browser scenario. +vi.mock('@fluentui/react-components', async (importOriginal) => { + const actual = await importOriginal(); + const passThrough = ({ children }: { children: ReactNode }): ReactNode => <>{children}; + return { + ...actual, + Menu: passThrough, + MenuTrigger: passThrough, + MenuPopover: passThrough, + MenuList: ({ children }: { children: ReactNode }): ReactNode =>
{children}
, + }; +}); + +class FakeClipboardItem { + constructor(public readonly items: Record) {} +} + +/** jsdom's Blob has no `text()`. */ +function readBlob(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (): void => resolve(reader.result as string); + reader.onerror = (): void => reject(reader.error ?? new Error('Could not read the blob')); + reader.readAsText(blob); + }); +} + +describe('ShareMenu', () => { + let host: HTMLDivElement; + let root: Root; + const copyText = vi.fn<(text: string) => Promise>(); + const buildMarkdown = vi.fn((): string => '# Full report'); + const buildRichText = vi.fn(() => ({ html: '

Rich

', plainText: '# Summary' })); + + beforeEach(async () => { + copyText.mockReset().mockResolvedValue(true); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => + root.render( + + + , + ), + ); + }); + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); + Reflect.deleteProperty(navigator, 'clipboard'); + }); + + const shareButton = (): HTMLButtonElement => { + const button = [...host.querySelectorAll('button')].find((b) => b.textContent === 'Share'); + if (!button) throw new Error('Share button not found'); + return button; + }; + + async function choose(label: string): Promise { + await act(async () => shareButton().click()); + const item = [...document.querySelectorAll('[role="menuitem"]')].find( + (element) => document.getElementById(element.getAttribute('aria-labelledby') ?? '')?.textContent === label, + ); + if (!item) throw new Error(`Menu item ${label} not found`); + await act(async () => item.click()); + } + + async function announcement(): Promise { + // The announcer clears, then sets its message 100 ms later. + await act(async () => new Promise((resolve) => setTimeout(resolve, 150))); + return host.querySelector('[role="status"]')?.textContent ?? ''; + } + + it('names each item by its label and describes it with the second line', async () => { + await act(async () => shareButton().click()); + const items = [...document.querySelectorAll('[role="menuitem"]')]; + expect(items).toHaveLength(2); + const describe = (item: HTMLElement): [string | null | undefined, string | null | undefined] => [ + document.getElementById(item.getAttribute('aria-labelledby') ?? '')?.textContent, + document.getElementById(item.getAttribute('aria-describedby') ?? '')?.textContent, + ]; + expect(describe(items[0])).toEqual(['Copy as Markdown', 'For issues, docs and AI agents']); + expect(describe(items[1])).toEqual([ + 'Copy as rich text', + 'The same report, formatted for email, Teams and Word', + ]); + }); + + it('copies the full Markdown report through the host and confirms it', async () => { + await choose('Copy as Markdown'); + expect(copyText).toHaveBeenCalledExactlyOnceWith('# Full report'); + expect(await announcement()).toBe('Copied the report as Markdown'); + expect(shareButton().textContent).toBe('Share'); + }); + + it('writes HTML with a plain-text alternative to the webview clipboard', async () => { + const write = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('ClipboardItem', FakeClipboardItem); + Object.defineProperty(navigator, 'clipboard', { value: { write }, configurable: true }); + await choose('Copy as rich text'); + expect(write).toHaveBeenCalledOnce(); + const [[[item]]] = write.mock.calls as [[FakeClipboardItem[]]]; + expect(Object.keys(item.items)).toEqual(['text/html', 'text/plain']); + expect(item.items['text/html'].type).toBe('text/html'); + expect(await readBlob(item.items['text/html'])).toBe('

Rich

'); + expect(await readBlob(item.items['text/plain'])).toBe('# Summary'); + expect(copyText).not.toHaveBeenCalled(); + expect(await announcement()).toBe('Copied the report as rich text'); + }); + + it('falls back to plain text through the host when rich text cannot be written', async () => { + vi.stubGlobal('ClipboardItem', FakeClipboardItem); + Object.defineProperty(navigator, 'clipboard', { + value: { write: vi.fn().mockRejectedValue(new Error('NotAllowedError')) }, + configurable: true, + }); + await choose('Copy as rich text'); + expect(copyText).toHaveBeenCalledExactlyOnceWith('# Summary'); + expect(await announcement()).toBe('Rich text is not available here. Copied the report as plain text'); + }); + + it('announces a failure when nothing could be copied', async () => { + vi.stubGlobal('ClipboardItem', undefined); + copyText.mockResolvedValue(false); + await choose('Copy as rich text'); + expect(copyText).toHaveBeenCalledExactlyOnceWith('# Summary'); + expect(await announcement()).toBe('Could not copy to the clipboard'); + }); +}); diff --git a/src/webviews/documentdb/queryInsightsShared/ShareMenu.tsx b/src/webviews/documentdb/queryInsightsShared/ShareMenu.tsx new file mode 100644 index 000000000..3c2d58964 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/ShareMenu.tsx @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + Menu, + MenuButton, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, + useId, + type ButtonProps, +} from '@fluentui/react-components'; +import { CheckmarkRegular, DocumentTextRegular, MarkdownRegular, ShareRegular } from '@fluentui/react-icons'; +import * as l10n from '@vscode/l10n'; +import { type JSX } from 'react'; +import { useCopyFeedback, type CopiedWithMessage } from './useCopyFeedback'; +import { writeHtmlToClipboard } from './writeHtmlToClipboard'; + +export interface ShareReportContent { + /** Email-safe HTML for rich-text targets. */ + readonly html: string; + /** Plain-text alternative written with the HTML and used as the fallback. */ + readonly plainText: string; +} + +export interface ShareMenuProps { + /** The full Markdown report, including the model inputs. */ + buildMarkdown: () => string; + buildRichText: () => ShareReportContent; + /** Copies plain text through the extension host. Resolving to `false` counts as a failure. */ + copyText: (text: string) => Promise; + appearance?: ButtonProps['appearance']; +} + +interface ShareMenuItemProps { + icon: JSX.Element; + label: string; + description: string; + onClick: () => void; +} + +/** A menu item named by its label and described by its second line. */ +function ShareMenuItem({ icon, label, description, onClick }: ShareMenuItemProps): JSX.Element { + const labelId = useId('share-item-label'); + const descriptionId = useId('share-item-description'); + return ( + + {label} + + ); +} + +/** + * "Share" menu for the Query Insights report. Both options copy the same content: as Markdown, or as + * rich text (the Markdown rendered as styled HTML, with the Markdown as the plain-text alternative). After a copy the button icon becomes a checkmark for two seconds and the result is + * announced; the button label never changes. + */ +export function ShareMenu({ + buildMarkdown, + buildRichText, + copyText, + appearance = 'subtle', +}: ShareMenuProps): JSX.Element { + const feedback = useCopyFeedback(); + + const copyMarkdown = (): void => { + void feedback.run(() => copyText(buildMarkdown()), l10n.t('Copied the report as Markdown')); + }; + + const copyRichText = (): void => { + void feedback.run(async (): Promise => { + const content = buildRichText(); + if (await writeHtmlToClipboard(content.html, content.plainText)) { + return true; + } + const copied = await copyText(content.plainText); + if (copied === false) return false; + const fallback: CopiedWithMessage = { + copiedMessage: l10n.t('Rich text is not available here. Copied the report as plain text'), + }; + return fallback; + }, l10n.t('Copied the report as rich text')); + }; + + return ( + <> + + + : } + > + {l10n.t('Share')} + + + + + } + label={l10n.t('Copy as Markdown')} + description={l10n.t('For issues, docs and AI agents')} + onClick={copyMarkdown} + /> + } + label={l10n.t('Copy as rich text')} + description={l10n.t('The same report, formatted for email, Teams and Word')} + onClick={copyRichText} + /> + + + + {feedback.announcer} + + ); +} diff --git a/src/webviews/documentdb/queryInsightsShared/analysisProfilePresentation.ts b/src/webviews/documentdb/queryInsightsShared/analysisProfilePresentation.ts new file mode 100644 index 000000000..3107bfd03 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/analysisProfilePresentation.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { type QueryInsightsAnalysisProfile } from '../collectionView/types/queryInsights'; + +interface AnalysisProfilePresentation { + resultLabel: string; + resultTooltip: string; + resultSummary: (value: string) => string; +} + +export function getAnalysisProfilePresentation(profile: QueryInsightsAnalysisProfile): AnalysisProfilePresentation { + if (profile === 'count') { + return { + resultLabel: l10n.t('Documents counted'), + resultTooltip: l10n.t( + 'The count result from the execution stage nCounted field, not the number of documents returned by the count command.', + ), + resultSummary: (value) => l10n.t('{0} counted', value), + }; + } + if (profile === 'aggregate') { + return { + resultLabel: l10n.t('Documents passed to pipeline'), + resultTooltip: l10n.t( + 'Documents produced by the initial cursor stage, not the final pipeline output. Selectivity and efficiency describe only this initial stage.', + ), + resultSummary: (value) => l10n.t('{0} passed to pipeline', value), + }; + } + return { + resultLabel: l10n.t('Documents Returned'), + resultTooltip: l10n.t('Number of documents returned by the query'), + resultSummary: (value) => l10n.t('{0} returned', value), + }; +} diff --git a/src/webviews/documentdb/queryInsightsShared/designPreview/HeaderChipOption.tsx b/src/webviews/documentdb/queryInsightsShared/designPreview/HeaderChipOption.tsx new file mode 100644 index 000000000..97fc867c4 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/designPreview/HeaderChipOption.tsx @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Dev-only design preview (POC); strings intentionally not localized. + +import { + Button, + Divider, + Dropdown, + Field, + Link, + makeStyles, + mergeClasses, + Option, + OptionGroup, + Popover, + PopoverSurface, + PopoverTrigger, + Text, + tokens, +} from '@fluentui/react-components'; +import { ChevronDownRegular, SparkleRegular } from '@fluentui/react-icons'; +import { useState, type JSX } from 'react'; +import { groupPreviewModels, previewModel } from './mockModels'; +import { type OptionProps } from './ModelPillOption'; +import { contextLabel, CostBadge, costExplanation, InsightsCardShell, usePreviewStyles } from './previewParts'; + +const useChipStyles = makeStyles({ + panel: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM, width: '340px', maxWidth: '100%' }, + facts: { + display: 'grid', + gridTemplateColumns: 'max-content minmax(0, 1fr)', + columnGap: tokens.spacingHorizontalL, + rowGap: tokens.spacingVerticalXS, + margin: 0, + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, + '& dt': { color: tokens.colorNeutralForeground3 }, + '& dd': { + margin: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: tokens.spacingVerticalXXS, + }, + }, + surface: { padding: tokens.spacingHorizontalL }, + muted: { color: tokens.colorNeutralForeground3 }, +}); + +/** The popover body: model dropdown, cost and context facts, prompt, Explore. */ +export function ModelSettingsPanel({ + modelId, + onSelect, +}: { + modelId: string; + onSelect?: (id: string) => void; +}): JSX.Element { + const chip = useChipStyles(); + const model = previewModel(modelId); + const credits = model.isUtility + ? costExplanation(model) + : `Credits per 1M tokens: in ${model.inputCost?.toLocaleString('en-US')} · out ${model.outputCost?.toLocaleString('en-US')}`; + return ( +
+ Model for AI Performance Insights + + { + if (data.optionValue) onSelect?.(data.optionValue); + }} + > + {groupPreviewModels().map((group) => ( + + {group.models.map((item) => ( + + ))} + + ))} + + +
+
Cost
+
+ + {credits} +
+
Context
+
{contextLabel(model)} tokens
+
Prompt
+
Built-in
+
+ + Saved for Query Insights everywhere. Thinking level follows VS Code Chat. + + + Explore with your own input… +
+ ); +} + +export function StaticModelSettings({ selectedId }: { selectedId: string }): JSX.Element { + const styles = usePreviewStyles(); + const chip = useChipStyles(); + return ( +
+ +
+ ); +} + +/** Option E: the model is a chip in the card header that opens a small settings popover. */ +export function HeaderChipOption({ initialModelId, anchorRef }: OptionProps): JSX.Element { + const styles = usePreviewStyles(); + const [modelId, setModelId] = useState(initialModelId); + const model = previewModel(modelId); + const headerChip = ( + + + + + + + + + + + ); + return ( + +
+ + +
+
+ ); +} diff --git a/src/webviews/documentdb/queryInsightsShared/designPreview/ModelPillOption.tsx b/src/webviews/documentdb/queryInsightsShared/designPreview/ModelPillOption.tsx new file mode 100644 index 000000000..a6c100f81 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/designPreview/ModelPillOption.tsx @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Dev-only design preview (POC); strings intentionally not localized. + +import { Button, Link, MenuButton, Tooltip } from '@fluentui/react-components'; +import { SparkleRegular } from '@fluentui/react-icons'; +import { useState, type JSX, type RefObject } from 'react'; +import { previewModel } from './mockModels'; +import { + contextLabel, + CostBadge, + costExplanation, + costLabel, + InsightsCardShell, + MetaItems, + ModelMenu, + usePreviewStyles, +} from './previewParts'; + +export interface OptionProps { + readonly initialModelId: string; + /** The model trigger, so a frame can float a static open menu under it. */ + readonly anchorRef?: RefObject; +} + +/** Option A: primary button + VS Code chat-style model pill; one muted footer line. */ +export function ModelPillOption({ initialModelId, anchorRef }: OptionProps): JSX.Element { + const styles = usePreviewStyles(); + const [modelId, setModelId] = useState(initialModelId); + const model = previewModel(modelId); + return ( + +
+ + + + + + + {model.name} + + + + + + +
+
+ + Explore with your own input… + , + + Learn more + , + ]} + /> +
+
+ ); +} diff --git a/src/webviews/documentdb/queryInsightsShared/designPreview/QueryInsightsDesignPreview.tsx b/src/webviews/documentdb/queryInsightsShared/designPreview/QueryInsightsDesignPreview.tsx new file mode 100644 index 000000000..023687356 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/designPreview/QueryInsightsDesignPreview.tsx @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Link, makeStyles, Text, tokens } from '@fluentui/react-components'; +import { useState, type JSX } from 'react'; +import { type AvailableModelInfo } from '../../../../services/ai/modelSelection'; +import { GetPerformanceInsightsCard } from '../../collectionView/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard'; +import { ModelPicker } from '../ModelPicker'; +import { BODY_TEXT } from './previewParts'; + +const useStyles = makeStyles({ + page: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalL, + padding: `${tokens.spacingVerticalXXL} ${tokens.spacingHorizontalXXL}`, + }, +}); + +/** Dev-only L2 route: the selected card design with fixture models, not the live Copilot catalog. */ +export function QueryInsightsDesignPreview(): JSX.Element { + const styles = useStyles(); + const [selected, setSelected] = useState(); + const noop = (): void => undefined; + return ( +
+ + Query Insights — selected card design + + + Preview fixture models only. The installed Query Insights tab and wizard use the live Copilot catalog. + + + } + mayUsePremiumRequests={!selected?.isUtility} + secondaryLink={Explore with your own input…} + onGetInsights={noop} + onLearnMore={noop} + onCancel={noop} + onLearnMoreUtilityModel={noop} + /> +
+ ); +} diff --git a/src/webviews/documentdb/queryInsightsShared/designPreview/QuickPickOption.tsx b/src/webviews/documentdb/queryInsightsShared/designPreview/QuickPickOption.tsx new file mode 100644 index 000000000..2ece48f1a --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/designPreview/QuickPickOption.tsx @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Dev-only design preview (POC); strings intentionally not localized. + +import { Button, Link, makeStyles, mergeClasses, tokens } from '@fluentui/react-components'; +import { CheckmarkRegular, SparkleRegular } from '@fluentui/react-icons'; +import { type JSX } from 'react'; +import { type AvailableModelInfo } from '../../../../services/ai/modelSelection'; +import { groupPreviewModels, previewModel } from './mockModels'; +import { type OptionProps } from './ModelPillOption'; +import { + contextLabel, + CostBadge, + costExplanation, + costLabel, + InsightsCardShell, + MetaItems, + usePreviewStyles, +} from './previewParts'; + +/** Option D: the card names the model; "Change model…" asks the host for vscode.window.showQuickPick. */ +export function QuickPickOption({ initialModelId, anchorRef }: OptionProps): JSX.Element { + const styles = usePreviewStyles(); + const model = previewModel(initialModelId); + return ( + +
+ + +
+
+ + + Uses {model.name} + + + + + + Change model… + + +
+
+ + Explore with your own input… + , + ]} + /> +
+
+ ); +} + +const useQuickPickStyles = makeStyles({ + widget: { + width: '560px', + maxWidth: '100%', + padding: tokens.spacingHorizontalXS, + backgroundColor: tokens.colorNeutralBackground1, + color: tokens.colorNeutralForeground1, + borderRadius: tokens.borderRadiusLarge, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + boxShadow: tokens.shadow28, + fontSize: tokens.fontSizeBase300, + lineHeight: tokens.lineHeightBase300, + }, + title: { + textAlign: 'center', + padding: `${tokens.spacingVerticalXS} 0`, + color: tokens.colorNeutralForeground2, + fontSize: tokens.fontSizeBase200, + }, + input: { + margin: `0 ${tokens.spacingHorizontalXS} ${tokens.spacingVerticalXS}`, + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + border: `${tokens.strokeWidthThin} solid ${tokens.colorBrandStroke1}`, + borderRadius: tokens.borderRadiusSmall, + color: tokens.colorNeutralForeground4, + backgroundColor: tokens.colorNeutralBackground3, + }, + row: { + display: 'grid', + gridTemplateColumns: '16px minmax(0, 1fr) auto', + columnGap: tokens.spacingHorizontalS, + alignItems: 'center', + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`, + borderRadius: tokens.borderRadiusMedium, + }, + separator: { borderTop: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}` }, + // The active row inherits one selected foreground, as VS Code's list.activeSelectionForeground does. + active: { + backgroundColor: tokens.colorNeutralBackground1Selected, + color: tokens.colorNeutralForeground1Selected, + '& span': { color: 'inherit' }, + }, + label: { + display: 'flex', + alignItems: 'baseline', + gap: tokens.spacingHorizontalS, + minWidth: 0, + whiteSpace: 'nowrap', + }, + description: { + color: tokens.colorNeutralForeground3, + fontSize: tokens.fontSizeBase200, + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + detail: { + gridColumn: 2, + color: tokens.colorNeutralForeground3, + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, + }, + groupLabel: { color: tokens.colorNeutralForeground3, fontSize: tokens.fontSizeBase200 }, +}); + +function creditsDetail(model: AvailableModelInfo): string { + if (model.isUtility) return costExplanation(model); + const parts = [ + model.inputCost !== undefined ? `input ${model.inputCost.toLocaleString('en-US')}` : undefined, + model.outputCost !== undefined ? `output ${model.outputCost.toLocaleString('en-US')}` : undefined, + model.cacheCost !== undefined ? `cache read ${model.cacheCost.toLocaleString('en-US')}` : undefined, + ].filter(Boolean); + return parts.length > 0 ? `Credits per 1M tokens: ${parts.join(' · ')}` : costExplanation(model); +} + +/** + * Faithful static mock of `vscode.window.showQuickPick` with QuickPickItemKind.Separator groups, + * description = cost tier · context window, detail = credits. Plain text only (no badge colours). + */ +export function QuickPickMock({ selectedId }: { selectedId: string }): JSX.Element { + const qp = useQuickPickStyles(); + return ( +
+
Copilot model for Query Insights
+
Select a model (current: {previewModel(selectedId).name})
+ {groupPreviewModels().map((group, groupIndex) => + group.models.map((model, index) => ( +
0 && index === 0 && qp.separator, + model.id === selectedId && qp.active, + )} + > + {model.id === selectedId && } + + {model.name} + + {[costLabel(model), contextLabel(model) && `${contextLabel(model)} context`] + .filter(Boolean) + .join(' · ')} + + + {index === 0 ? group.label : ''} + {creditsDetail(model)} +
+ )), + )} +
+ ); +} diff --git a/src/webviews/documentdb/queryInsightsShared/designPreview/SettingsStripOption.tsx b/src/webviews/documentdb/queryInsightsShared/designPreview/SettingsStripOption.tsx new file mode 100644 index 000000000..5074e135f --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/designPreview/SettingsStripOption.tsx @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Dev-only design preview (POC); strings intentionally not localized. + +import { Button, Divider, Link, makeStyles, mergeClasses, Text, tokens } from '@fluentui/react-components'; +import { SparkleRegular } from '@fluentui/react-icons'; +import { useState, type JSX } from 'react'; +import { previewModel } from './mockModels'; +import { type OptionProps } from './ModelPillOption'; +import { CostBadge, InsightsCardShell, ModelMenu, usePreviewStyles } from './previewParts'; + +const useStripStyles = makeStyles({ + divider: { marginTop: tokens.spacingVerticalL, marginBottom: tokens.spacingVerticalS }, + strip: { + display: 'grid', + gridTemplateColumns: 'max-content minmax(0, 1fr) auto', + alignItems: 'center', + columnGap: tokens.spacingHorizontalL, + rowGap: tokens.spacingVerticalXS, + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, + }, + label: { color: tokens.colorNeutralForeground3 }, + value: { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: tokens.spacingHorizontalS, minWidth: 0 }, + explore: { gridColumn: 3, gridRow: 1, justifySelf: 'end' }, +}); + +/** Option C: the card body stays clean; a divider and a compact settings strip hold model and prompt. */ +export function SettingsStripOption({ initialModelId, anchorRef }: OptionProps): JSX.Element { + const styles = usePreviewStyles(); + const strip = useStripStyles(); + const [modelId, setModelId] = useState(initialModelId); + const model = previewModel(modelId); + return ( + +
+ + +
+ +
+ + Model + + + + {model.name} + + + + + + Change + + + + + + Explore with your own input… + + + Prompt + + Built-in +
+
+ ); +} diff --git a/src/webviews/documentdb/queryInsightsShared/designPreview/SplitButtonOption.tsx b/src/webviews/documentdb/queryInsightsShared/designPreview/SplitButtonOption.tsx new file mode 100644 index 000000000..c7578a351 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/designPreview/SplitButtonOption.tsx @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Dev-only design preview (POC); strings intentionally not localized. + +import { + Button, + Menu, + MenuDivider, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, + SplitButton, + type MenuButtonProps, +} from '@fluentui/react-components'; +import { OpenRegular, SparkleRegular } from '@fluentui/react-icons'; +import { useState, type JSX } from 'react'; +import { previewModel, previewModels } from './mockModels'; +import { type OptionProps } from './ModelPillOption'; +import { + CostBadge, + InsightsCardShell, + MetaItems, + ModelMenuGroups, + StaticModelMenu, + usePreviewStyles, +} from './previewParts'; + +function SplitMenuExtra(): JSX.Element { + return ( + <> + + }>Explore with your own input… + + ); +} + +/** The split button's menu: the model radio group plus the secondary "Explore…" action. */ +export function StaticSplitMenu({ selectedId }: { selectedId: string }): JSX.Element { + return } />; +} + +/** Option B: split button; the chevron holds model choice and Explore, meta text sits under the row. */ +export function SplitButtonOption({ initialModelId, anchorRef }: OptionProps): JSX.Element { + const styles = usePreviewStyles(); + const [modelId, setModelId] = useState(initialModelId); + const model = previewModel(modelId); + return ( + +
+ + { + if (data.checkedItems[0]) setModelId(data.checkedItems[0]); + }} + > + + {(triggerProps: MenuButtonProps) => ( + } + menuButton={{ + ...triggerProps, + 'aria-label': 'Choose the Copilot model and more options', + }} + > + Get AI Performance Insights + + )} + + + + + + + + + + +
+
+ + + Using {model.name} + + + , + 'Built-in prompt', + ]} + /> +
+
+ ); +} diff --git a/src/webviews/documentdb/queryInsightsShared/designPreview/mockModels.ts b/src/webviews/documentdb/queryInsightsShared/designPreview/mockModels.ts new file mode 100644 index 000000000..2f180d980 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/designPreview/mockModels.ts @@ -0,0 +1,143 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type AvailableModelInfo, type AvailableModelsResult } from '../../../../services/ai/modelSelection'; + +/** + * Mock data for the dev-only design preview (also served to the "Current" card by the L2 scenario). + * Shapes match what `ai.getAvailableModels` returns; credits are per 1M tokens (F7). + */ +export const previewModels: readonly AvailableModelInfo[] = [ + { + id: 'copilot-utility', + name: 'Copilot utility', + family: 'copilot-utility', + vendor: 'copilot', + version: '1', + maxInputTokens: 128000, + isUtility: true, + pricing: 'Free', + priceCategory: 'low', + category: 'lightweight', + inputCost: 0, + outputCost: 0, + cacheCost: 0, + supportsToolCalling: true, + supportsImageToText: false, + }, + { + id: 'gpt-6.1-sol', + name: 'GPT-6.1 Sol', + family: 'gpt-6.1-sol', + vendor: 'copilot', + version: '2026-09-15', + maxInputTokens: 400000, + isUtility: false, + priceCategory: 'medium', + category: 'powerful', + inputCost: 125, + outputCost: 1000, + cacheCost: 12.5, + cacheWriteCost: 156, + supportsToolCalling: true, + supportsImageToText: true, + }, + { + id: 'claude-opus-5.5', + name: 'Claude Opus 5.5', + family: 'claude-opus-5.5', + vendor: 'copilot', + version: '5.5', + maxInputTokens: 1000000, + isUtility: false, + priceCategory: 'very_high', + category: 'powerful', + inputCost: 500, + outputCost: 2500, + cacheCost: 50, + cacheWriteCost: 625, + longContextInputCost: 1000, + longContextOutputCost: 3750, + supportsToolCalling: true, + supportsImageToText: true, + }, + { + id: 'claude-sonnet-5.5', + name: 'Claude Sonnet 5.5', + family: 'claude-sonnet-5.5', + vendor: 'copilot', + version: '5.5', + maxInputTokens: 1000000, + isUtility: false, + priceCategory: 'high', + category: 'versatile', + inputCost: 300, + outputCost: 1500, + cacheCost: 30, + cacheWriteCost: 375, + supportsToolCalling: true, + supportsImageToText: true, + }, + { + id: 'gemini-3.8-flash', + name: 'Gemini 3.8 Flash', + family: 'gemini-3.8-flash', + vendor: 'copilot', + version: '3.8', + maxInputTokens: 1000000, + isUtility: false, + priceCategory: 'low', + category: 'lightweight', + inputCost: 30, + outputCost: 250, + cacheCost: 3, + supportsToolCalling: true, + supportsImageToText: true, + }, + { + id: 'gpt-5.6-sol-fast', + name: 'GPT-5.6 Sol Fast', + family: 'gpt-5.6-sol', + vendor: 'copilot', + version: '5.6', + maxInputTokens: 128000, + isUtility: false, + }, +]; + +export const PREVIEW_SAVED_MODEL_ID = 'gpt-6.1-sol'; + +export const previewAvailableModels: AvailableModelsResult = { + models: [...previewModels], + savedModelId: PREVIEW_SAVED_MODEL_ID, + effectiveModelId: PREVIEW_SAVED_MODEL_ID, + effectiveReason: 'saved', +}; + +export function previewModel(id: string): AvailableModelInfo { + return previewModels.find((model) => model.id === id) ?? previewModels[0]; +} + +export interface PreviewModelGroup { + readonly key: string; + readonly label: string; + readonly models: readonly AvailableModelInfo[]; +} + +/** Utility first, then the API categories in VS Code's order (as in ModelPicker). */ +export function groupPreviewModels(models: readonly AvailableModelInfo[] = previewModels): PreviewModelGroup[] { + const groups: PreviewModelGroup[] = [ + { key: 'utility', label: 'Utility', models: models.filter((model) => model.isUtility) }, + ...[ + { key: 'powerful', label: 'Powerful' }, + { key: 'versatile', label: 'Versatile' }, + { key: 'lightweight', label: 'Lightweight' }, + ].map((group) => ({ + ...group, + models: models.filter((model) => !model.isUtility && model.category === group.key), + })), + ]; + return groups.filter((group) => group.models.length > 0); +} diff --git a/src/webviews/documentdb/queryInsightsShared/designPreview/previewParts.tsx b/src/webviews/documentdb/queryInsightsShared/designPreview/previewParts.tsx new file mode 100644 index 000000000..c4afd6905 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/designPreview/previewParts.tsx @@ -0,0 +1,337 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Dev-only design preview (POC): strings are not localized on purpose; the chosen option is +// rebuilt with l10n in GetPerformanceInsightsCard once the operator picks one. + +import { + Badge, + Card, + CardHeader, + makeStyles, + Menu, + MenuGroup, + MenuGroupHeader, + MenuItemRadio, + MenuList, + MenuPopover, + MenuTrigger, + mergeClasses, + Text, + tokens, + type MenuProps, +} from '@fluentui/react-components'; +import { SparkleRegular } from '@fluentui/react-icons'; +import { Fragment, useLayoutEffect, useRef, useState, type JSX, type ReactNode, type RefObject } from 'react'; +import { type AvailableModelInfo } from '../../../../services/ai/modelSelection'; +import { formatTokenCount } from '../../../../utils/formatTokenCount'; +import '../../collectionView/queryInsightsTab/components/optimizationCards/baseOptimizationCard.scss'; +import '../../collectionView/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.scss'; +import { groupPreviewModels, previewModels } from './mockModels'; + +export const usePreviewStyles = makeStyles({ + muted: { color: tokens.colorNeutralForeground3 }, + cardTitle: { display: 'flex', alignItems: 'center', gap: tokens.spacingHorizontalS, flexWrap: 'wrap' }, + cardHeader: { marginBottom: tokens.spacingVerticalS }, + cardBody: { display: 'block', marginBottom: tokens.spacingVerticalL }, + cardContent: { flex: 1, minWidth: 0 }, + actions: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalS, + }, + actionsSpacer: { flexGrow: 1 }, + metaLine: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + columnGap: tokens.spacingHorizontalS, + rowGap: tokens.spacingVerticalXXS, + marginTop: tokens.spacingVerticalS, + color: tokens.colorNeutralForeground3, + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, + }, + smallLink: { fontSize: tokens.fontSizeBase200, lineHeight: tokens.lineHeightBase200 }, + strong: { color: tokens.colorNeutralForeground1, fontWeight: tokens.fontWeightSemibold }, + pillContent: { + display: 'inline-flex', + alignItems: 'center', + gap: tokens.spacingHorizontalS, + fontWeight: tokens.fontWeightRegular, + }, + + staticSurface: { + width: 'max-content', + maxWidth: '420px', + padding: tokens.spacingHorizontalXS, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorTransparentStroke}`, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow16, + color: tokens.colorNeutralForeground1, + }, + frame: { position: 'relative' }, + floating: { position: 'absolute', zIndex: 1, width: 'max-content' }, + hidden: { visibility: 'hidden' }, + anchor: { display: 'inline-flex' }, +}); + +export const BODY_TEXT = + 'Get personalized recommendations to optimize your query performance. AI will analyze your cluster configuration, index usage, execution plan, and more to suggest specific improvements.'; + +export function contextLabel(model: AvailableModelInfo): string { + return model.maxInputTokens > 0 ? formatTokenCount(model.maxInputTokens, 'en-US') : ''; +} + +export function costLabel(model: AvailableModelInfo): string | undefined { + if (model.isUtility) return undefined; + switch (model.priceCategory) { + case 'low': + return 'Low cost'; + case 'medium': + return 'Medium cost'; + case 'high': + return 'High cost'; + case 'very_high': + return 'Very high cost'; + case undefined: + return undefined; + default: + return `${model.priceCategory.charAt(0).toUpperCase()}${model.priceCategory.slice(1)} cost`; + } +} + +/** One-line plain-language cost explanation (tooltip / quick pick detail). */ +export function costExplanation(model: AvailableModelInfo): string { + return model.isUtility + ? 'No additional cost for most GitHub Copilot subscribers.' + : 'Uses premium requests from your Copilot plan.'; +} + +/** House style; VS Code tints: low/medium neutral, utility has no badge, high/very_high warning. */ +export function CostBadge({ model }: { model: AvailableModelInfo }): JSX.Element | null { + const label = costLabel(model); + if (!label) return null; + const warning = model.priceCategory === 'high' || model.priceCategory === 'very_high'; + return ( + + {label} + + ); +} + +export function Dot(): JSX.Element { + return ·; +} + +/** Joins inline meta items with a muted middle dot. */ +export function MetaItems({ items }: { items: readonly ReactNode[] }): JSX.Element { + return ( + <> + {items.filter(Boolean).map((item, index) => ( + + {index > 0 && } + {item} + + ))} + + ); +} + +/** Same shell as GetPerformanceInsightsCard: brand tint, sparkle, title and disclaimer. */ +export function InsightsCardShell({ + children, + headerAction, +}: { + children: ReactNode; + headerAction?: JSX.Element; +}): JSX.Element { + const styles = usePreviewStyles(); + const disclaimer = ( + + AI responses may be inaccurate + + ); + return ( + +
+ +
+ + AI Performance Insights + + } + action={headerAction ?? disclaimer} + /> + + {BODY_TEXT} + + {children} + {headerAction &&
{disclaimer}
} +
+
+
+ ); +} + +export function ModelMenuGroups({ models }: { models: readonly AvailableModelInfo[] }): JSX.Element { + return ( + <> + {groupPreviewModels(models).map((group) => ( + + {group.label} + {group.models.map((model) => ( + } + > + {model.name} + + ))} + + ))} + + ); +} + +export interface ModelMenuProps { + readonly selectedId: string; + readonly onSelect: (id: string) => void; + /** The trigger element (wrapped in MenuTrigger). */ + readonly children: JSX.Element; + /** Extra items after the model groups (e.g. B's "Explore…" action). */ + readonly extra?: ReactNode; + readonly positioning?: MenuProps['positioning']; +} + +/** Interactive model menu: radio items grouped by category, cost badge per item. */ +export function ModelMenu({ + selectedId, + onSelect, + children, + extra, + positioning = 'below-start', +}: ModelMenuProps): JSX.Element { + return ( + { + if (data.name === 'model' && data.checkedItems[0]) onSelect(data.checkedItems[0]); + }} + > + {children} + + + + {extra} + + + + ); +} + +/** A static, non-interactive copy of the open model menu, for side-by-side screenshots. */ +export function StaticModelMenu({ selectedId, extra }: { selectedId: string; extra?: ReactNode }): JSX.Element { + const styles = usePreviewStyles(); + return ( +
+ + + {extra} + +
+ ); +} + +/** + * Renders `card` with `surface` floating under `anchorRef`, like an open popup, and reserves + * enough height so the next frame does not overlap. The surface is inert (screenshot only). + */ +export function PopoverFrame({ + card, + surface, + anchorRef, + align = 'start', +}: { + card: ReactNode; + surface: ReactNode; + anchorRef: RefObject; + align?: 'start' | 'end'; +}): JSX.Element { + const styles = usePreviewStyles(); + const frameRef = useRef(null); + const surfaceRef = useRef(null); + const [placement, setPlacement] = useState<{ top: number; left?: number; right?: number; minHeight: number }>(); + + useLayoutEffect(() => { + const update = (): void => { + const frame = frameRef.current; + const anchor = anchorRef.current; + const floating = surfaceRef.current; + if (!frame || !anchor || !floating) return; + const frameRect = frame.getBoundingClientRect(); + const anchorRect = anchor.getBoundingClientRect(); + const top = anchorRect.bottom - frameRect.top + 4; + const next = { + top, + left: align === 'start' ? anchorRect.left - frameRect.left : undefined, + right: align === 'end' ? frameRect.right - anchorRect.right : undefined, + minHeight: Math.ceil(top + floating.offsetHeight + 8), + }; + setPlacement((previous) => + previous && + previous.top === next.top && + previous.left === next.left && + previous.right === next.right && + previous.minHeight === next.minHeight + ? previous + : next, + ); + }; + update(); + const frameId = requestAnimationFrame(update); + void document.fonts?.ready.then(update); + window.addEventListener('resize', update); + return () => { + cancelAnimationFrame(frameId); + window.removeEventListener('resize', update); + }; + }, [anchorRef, align]); + + return ( +
+ {card} +
+ {surface} +
+
+ ); +} diff --git a/src/webviews/documentdb/queryInsightsShared/queryInsightsModelsRouter.ts b/src/webviews/documentdb/queryInsightsShared/queryInsightsModelsRouter.ts new file mode 100644 index 000000000..8d7c27763 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/queryInsightsModelsRouter.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { z } from 'zod'; +import { getAvailableModels, setSavedModelId, type AvailableModelsResult } from '../../../services/ai/modelSelection'; +import { publicProcedureWithTelemetry, router } from '../../_integration/trpc'; + +export const queryInsightsModelsRouter = router({ + getAvailableModels: publicProcedureWithTelemetry.query(async (): Promise => { + return getAvailableModels(); + }), + setSelectedModel: publicProcedureWithTelemetry + .input(z.object({ modelId: z.string().min(1).nullable() })) + .mutation(async ({ input }): Promise => { + await setSavedModelId(input.modelId); + return getAvailableModels(); + }), +}); diff --git a/src/webviews/documentdb/queryInsightsShared/usageByline.test.ts b/src/webviews/documentdb/queryInsightsShared/usageByline.test.ts new file mode 100644 index 000000000..b4951759e --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/usageByline.test.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { usageByline } from './usageByline'; + +describe('usageByline', () => { + it('omits unknown fields', () => { + expect(usageByline(undefined)).toBe(''); + expect(usageByline({ promptTokens: 10 })).toBe(''); + }); + it('formats exact zero, tiny credits and up to two decimals', () => { + expect(usageByline({ credits: 0 })).toBe(' · 0 credits'); + expect(usageByline({ credits: 0.0001 })).toBe(' · < 0.01 credits'); + expect(usageByline({ credits: 1.236, promptTokens: 1000, responseTokens: 20 })).toBe( + ' · 1.24 credits · 1,000 in / 20 out tokens', + ); + }); +}); diff --git a/src/webviews/documentdb/queryInsightsShared/usageByline.ts b/src/webviews/documentdb/queryInsightsShared/usageByline.ts new file mode 100644 index 000000000..b9d5bc455 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/usageByline.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { type QueryInsightsStreamUsage } from '../collectionView/types/queryInsightsStream'; + +export function usageByline(usage: QueryInsightsStreamUsage | undefined): string { + const parts: string[] = []; + if (usage?.credits !== undefined) { + const credits = + usage.credits > 0 && usage.credits < 0.01 + ? '< 0.01' + : new Intl.NumberFormat(navigator.language, { maximumFractionDigits: 2 }).format(usage.credits); + parts.push(l10n.t('{0} credits', credits)); + } + if (usage?.promptTokens !== undefined && usage.responseTokens !== undefined) { + const formatTokens = new Intl.NumberFormat(navigator.language); + parts.push( + l10n.t( + '{0} in / {1} out tokens', + formatTokens.format(usage.promptTokens), + formatTokens.format(usage.responseTokens), + ), + ); + } + return parts.length ? ` · ${parts.join(' · ')}` : ''; +} diff --git a/src/webviews/documentdb/queryInsightsShared/useCopyFeedback.tsx b/src/webviews/documentdb/queryInsightsShared/useCopyFeedback.tsx new file mode 100644 index 000000000..5e29f8c12 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/useCopyFeedback.tsx @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { useCallback, useEffect, useState, type JSX } from 'react'; +import { Announcer } from '../../components/accessibility'; + +/** Returned by a copy action to replace the success announcement, e.g. after a fallback. */ +export interface CopiedWithMessage { + readonly copiedMessage: string; +} + +export interface CopyFeedback { + /** True for two seconds after a successful copy: show a checkmark instead of the icon. */ + readonly copied: boolean; + /** + * Runs a copy action. Resolving to `false` or rejecting counts as a failure; resolving to + * {@link CopiedWithMessage} overrides `copiedMessage`. + */ + readonly run: (copy: () => unknown, copiedMessage?: string) => Promise; + /** Announces the result to screen readers. Render it next to the button. */ + readonly announcer: JSX.Element; +} + +function isCopiedWithMessage(value: unknown): value is CopiedWithMessage { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Partial).copiedMessage === 'string' + ); +} + +/** + * Copy confirmation shared by the Query Insights copy buttons: the caller swaps its icon for a + * checkmark while `copied` is true, and the announcer reports success or failure. Labels never + * change, so the accessible name stays stable under focus. + */ +export function useCopyFeedback(): CopyFeedback { + const [state, setState] = useState<{ status: 'idle' | 'copied' | 'failed'; key: number; message?: string }>({ + status: 'idle', + key: 0, + }); + + useEffect(() => { + if (state.status === 'idle') return undefined; + const timer = window.setTimeout(() => setState((previous) => ({ ...previous, status: 'idle' })), 2000); + return () => window.clearTimeout(timer); + }, [state]); + + const run = useCallback(async (copy: () => unknown, copiedMessage?: string): Promise => { + let ok: boolean; + let message = copiedMessage; + try { + const result = await copy(); + ok = result !== false; + if (isCopiedWithMessage(result)) message = result.copiedMessage; + } catch { + ok = false; + } + setState((previous) => ({ status: ok ? 'copied' : 'failed', key: previous.key + 1, message })); + }, []); + + const announcer = ( + + ); + + return { copied: state.status === 'copied', run, announcer }; +} diff --git a/src/webviews/documentdb/queryInsightsShared/writeHtmlToClipboard.ts b/src/webviews/documentdb/queryInsightsShared/writeHtmlToClipboard.ts new file mode 100644 index 000000000..1a1e857ed --- /dev/null +++ b/src/webviews/documentdb/queryInsightsShared/writeHtmlToClipboard.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Writes HTML with a plain-text alternative through the webview's async clipboard API. The host's + * `vscode.env.clipboard` is text-only, so rich content must be written here. Returns `false` when + * the API is unavailable or the write is refused, so the caller can fall back to plain text. + */ +export async function writeHtmlToClipboard(html: string, plainText: string): Promise { + if (typeof ClipboardItem === 'undefined' || typeof navigator.clipboard?.write !== 'function') { + return false; + } + try { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/html': new Blob([html], { type: 'text/html' }), + 'text/plain': new Blob([plainText], { type: 'text/plain' }), + }), + ]); + return true; + } catch { + return false; + } +} diff --git a/src/webviews/documentdb/queryInsightsStandalone/JsonPlanEditor.scss b/src/webviews/documentdb/queryInsightsStandalone/JsonPlanEditor.scss new file mode 100644 index 000000000..c03dda5f6 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsStandalone/JsonPlanEditor.scss @@ -0,0 +1,8 @@ +.query-insights-json-editor { + min-width: 0; + overflow: hidden; + border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border)); + border-radius: 4px; + + &:focus-within { border-color: var(--vscode-focusBorder); } +} diff --git a/src/webviews/documentdb/queryInsightsStandalone/JsonPlanEditor.test.tsx b/src/webviews/documentdb/queryInsightsStandalone/JsonPlanEditor.test.tsx new file mode 100644 index 000000000..1d0cdd1e3 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsStandalone/JsonPlanEditor.test.tsx @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; // eslint-disable-line import/no-internal-modules -- Public React DOM client entry point. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type MonacoEditorProps } from '../../components/MonacoEditor'; +import { JsonPlanEditor } from './JsonPlanEditor'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +let props: MonacoEditorProps; +vi.mock('../../components/MonacoEditor', () => ({ + MonacoEditor: (value: MonacoEditorProps): null => { + props = value; + return null; + }, +})); + +describe('Query Insights JSON editor', () => { + let host: HTMLDivElement; + let root: Root; + const model = { + getLineCount: (): number => 3, + getLineMaxColumn: (): number => 10, + isDisposed: (): boolean => false, + }; + const setModelMarkers = vi.fn(); + + beforeEach(() => { + setModelMarkers.mockClear(); + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + }); + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + }); + + let contentHeight = 0; + let resize: () => void = () => undefined; + const editorHeight = (): string | undefined => + host.querySelector('.query-insights-json-editor')?.style.height; + + it('sets and clears positioned parse markers, preserves lines and offers an Escape exit', async () => { + const onChange = vi.fn(); + await act(async () => + root.render( + , + ), + ); + expect(props.language).toBe('json'); + expect(props.options).toMatchObject({ + ariaLabel: 'Explain output', + wordWrap: 'on', + lineNumbers: 'on', + minimap: { enabled: false }, + folding: false, + automaticLayout: true, + glyphMargin: false, + lineNumbersMinChars: 3, + lineDecorationsWidth: 6, + }); + const editor = { + getModel: () => model, + getContentHeight: () => contentHeight, + onDidContentSizeChange: (listener: () => void) => { + resize = listener; + return { dispose: vi.fn() }; + }, + } as unknown as Parameters>[0]; + const monaco = { editor: { setModelMarkers }, MarkerSeverity: { Error: 8 } } as unknown as Parameters< + NonNullable + >[1]; + await act(async () => props.onMount?.(editor, monaco)); + // Grows with the content between its limits, then scrolls. + expect(editorHeight()).toBe('120px'); + contentHeight = 250; + await act(async () => resize()); + expect(editorHeight()).toBe('250px'); + contentHeight = 5000; + await act(async () => resize()); + expect(editorHeight()).toBe('400px'); + expect(setModelMarkers).toHaveBeenLastCalledWith(model, 'query-insights-input', [ + { + severity: 8, + message: 'Invalid JSON', + startLineNumber: 2, + endLineNumber: 2, + startColumn: 3, + endColumn: 4, + }, + ]); + await act(async () => props.onEscapeEditor?.()); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Outside Explain output editor'); + + await act(async () => + root.render(), + ); + expect(setModelMarkers).toHaveBeenLastCalledWith(model, 'query-insights-input', []); + expect(host.querySelector('.query-insights-json-editor-compact')).not.toBeNull(); + expect(editorHeight()).toBe('240px'); + }); +}); diff --git a/src/webviews/documentdb/queryInsightsStandalone/JsonPlanEditor.tsx b/src/webviews/documentdb/queryInsightsStandalone/JsonPlanEditor.tsx new file mode 100644 index 000000000..cab138935 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsStandalone/JsonPlanEditor.tsx @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +// eslint-disable-next-line import/no-internal-modules -- Monaco editor API types, matching the shared editor wrapper. +import type * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api'; +import { useCallback, useEffect, useRef, useState, type JSX } from 'react'; +import { MonacoEditor } from '../../components/MonacoEditor'; +import './JsonPlanEditor.scss'; +import { type InputIssue } from './queryInsightsStandaloneInput'; + +interface JsonPlanEditorProps { + value: string; + onChange: (value: string) => void; + ariaLabel: string; + issue?: InputIssue; + compact?: boolean; +} + +interface MountedEditor { + editor: monacoEditor.editor.IStandaloneCodeEditor; + monaco: typeof monacoEditor; +} + +const markerOwner = 'query-insights-input'; + +/** The editor grows with its content between these heights (px), then scrolls. */ +const heightLimits = { full: { min: 120, max: 400 }, compact: { min: 96, max: 240 } } as const; + +export function JsonPlanEditor({ + value, + onChange, + ariaLabel, + issue, + compact = false, +}: JsonPlanEditorProps): JSX.Element { + const [mounted, setMounted] = useState(); + const limits = compact ? heightLimits.compact : heightLimits.full; + const [height, setHeight] = useState(limits.min); + const exitRef = useRef(null); + const onMount = useCallback((editor: MountedEditor['editor'], monaco: MountedEditor['monaco']): void => { + setMounted({ editor, monaco }); + }, []); + + useEffect(() => { + const model = mounted?.editor.getModel(); + if (!mounted || !model || model.isDisposed()) return; + const markers: monacoEditor.editor.IMarkerData[] = []; + if (issue?.line !== undefined && issue.column !== undefined) { + const line = Math.max(1, Math.min(issue.line, model.getLineCount())); + const column = Math.max(1, Math.min(issue.column, model.getLineMaxColumn(line))); + markers.push({ + severity: mounted.monaco.MarkerSeverity.Error, + message: issue.message, + startLineNumber: line, + endLineNumber: line, + startColumn: column, + endColumn: column + 1, + }); + } + mounted.monaco.editor.setModelMarkers(model, markerOwner, markers); + return () => { + if (!model.isDisposed()) mounted.monaco.editor.setModelMarkers(model, markerOwner, []); + }; + }, [mounted, issue, value]); + + useEffect(() => { + if (!mounted) return undefined; + const { editor } = mounted; + const fit = (): void => setHeight(Math.min(limits.max, Math.max(limits.min, editor.getContentHeight()))); + fit(); + const subscription = editor.onDidContentSizeChange(fit); + return () => subscription.dispose(); + }, [mounted, limits.min, limits.max]); + + return ( +
+ onChange(text ?? '')} + onMount={onMount} + onEscapeEditor={() => exitRef.current?.focus()} + options={{ + ariaLabel, + lineNumbers: 'on', + minimap: { enabled: false }, + wordWrap: 'on', + folding: false, + automaticLayout: true, + fontSize: 13, + lineHeight: 19, + glyphMargin: false, + // Narrow gutter: line numbers stay (parse errors cite them); markers render as squiggles. + lineNumbersMinChars: 3, + lineDecorationsWidth: 6, + scrollBeyondLastLine: false, + scrollbar: { alwaysConsumeMouseWheel: false }, + renderValidationDecorations: 'on', + }} + /> +
+
+ ); +} diff --git a/src/webviews/documentdb/queryInsightsStandalone/QueryInsightsStandalone.tsx b/src/webviews/documentdb/queryInsightsStandalone/QueryInsightsStandalone.tsx new file mode 100644 index 000000000..5b5aa17f9 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsStandalone/QueryInsightsStandalone.tsx @@ -0,0 +1,1113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + Badge, + Button, + CounterBadge, + Field, + Input, + Link, + makeStyles, + Radio, + RadioGroup, + Select, + Spinner, + Table, + TableBody, + TableCell, + TableCellLayout, + TableRow, + Text, + tokens, + Tooltip, +} from '@fluentui/react-components'; +import { + ArrowLeftRegular, + CheckmarkCircleRegular, + CheckmarkRegular, + CopyRegular, + DataUsageRegular, + EditRegular, + ErrorCircleRegular, + ShieldLockRegular, +} from '@fluentui/react-icons'; +import { Collapse } from '@fluentui/react-motion-components-preview'; +import { + ContainerFooter, + ContainerHeader, + Wizard, + WizardStep, +} from '@microsoft/vscode-ext-webview-fluentui/components'; +import { useConfiguration } from '@microsoft/vscode-ext-webview/react'; +import * as l10n from '@vscode/l10n'; +import { Fragment, useCallback, useEffect, useId, useMemo, useRef, useState, type JSX, type ReactNode } from 'react'; +import { type CommandType } from '../../../commands/llmEnhancedCommands/indexAdvisorCommands'; +import { type AvailableModelInfo } from '../../../services/ai/modelSelection'; +import { useTrpcClient } from '../../_integration/useTrpcClient'; +import { Announcer } from '../../components/accessibility'; +import { MessageBlock } from '../../components/MessageBlock'; +import { type QueryInsightsState } from '../collectionView/collectionViewContext'; +import { + QueryInsightsDataSourceContext, + type QueryInsightsDataSource, +} from '../collectionView/queryInsightsTab/QueryInsightsDataSourceContext'; +import { QueryInsightsMain } from '../collectionView/queryInsightsTab/QueryInsightsTab'; +import { QueryInsightsDesignPreview } from '../queryInsightsShared/designPreview/QueryInsightsDesignPreview'; +import { ModelPicker } from '../queryInsightsShared/ModelPicker'; +import { QueryInsightsPreviewBadge } from '../queryInsightsShared/QueryInsightsPreviewBadge'; +import { JsonPlanEditor } from './JsonPlanEditor'; +import './queryInsightsStandalone.scss'; +import { type InputIssue, type StandaloneInspection } from './queryInsightsStandaloneInput'; +import { contextSnippets, explainSnippets, samplePlan, type SampleCommand, type ShellSnippet } from './samplePlans'; +import { type QueryInsightsPrefill, type StandaloneInput } from './types'; + +type StepId = 'welcome' | 'configure' | 'analyze'; +type OptionalRow = 'collectionStats' | 'indexes' | 'namespace' | 'clusterType'; +type AnalysisKind = QueryInsightsState['kind']; + +const useStyles = makeStyles({ + muted: { color: tokens.colorNeutralForeground2 }, + // Matches LocalQuickStart: a section owns its heading, which keeps more air above than below. + section: { display: 'flex', flexDirection: 'column', gap: '8px' }, + sections: { display: 'flex', flexDirection: 'column', gap: '28px', marginTop: '8px' }, + sectionHeading: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap', margin: 0 }, + // Same numbered list as LocalQuickStart's "What will happen in the Set up step". + needList: { display: 'flex', flexDirection: 'column', gap: '2px', margin: 0, padding: 0, listStyle: 'none' }, + needItem: { display: 'grid', gridTemplateColumns: '24px minmax(0, 1fr)', gap: '0 10px', padding: '7px 0' }, + needNumber: { justifySelf: 'start', alignSelf: 'start', marginTop: '1px' }, + needCopy: { display: 'flex', flexDirection: 'column', gap: '1px', minWidth: 0 }, + needTitle: { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px' }, + footerNote: { overflowWrap: 'anywhere' }, + // Keeps the picker's own error text under the dropdown instead of between the buttons. + footerModel: { minWidth: 0, maxWidth: '100%' }, + prefilledNote: { + display: 'inline-flex', + alignItems: 'center', + gap: '4px', + color: tokens.colorPaletteGreenForeground1, + }, + snippetList: { display: 'flex', flexDirection: 'column', gap: '6px' }, + snippetGroupLabel: { marginTop: '6px' }, + snippet: { + display: 'grid', + gridTemplateColumns: '84px minmax(0, 1fr) auto', + alignItems: 'start', + gap: '8px', + padding: '4px 4px 4px 10px', + borderRadius: tokens.borderRadiusMedium, + border: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: tokens.colorNeutralBackground2, + '@media (max-width: 480px)': { gridTemplateColumns: 'minmax(0, 1fr) auto' }, + }, + snippetLabel: { + paddingTop: '5px', + color: tokens.colorNeutralForeground2, + '@media (max-width: 480px)': { display: 'none' }, + }, + code: { + margin: 0, + paddingTop: '5px', + minWidth: 0, + whiteSpace: 'pre-wrap', + overflowWrap: 'anywhere', + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase300, + color: tokens.colorNeutralForeground1, + }, + mono: { fontFamily: tokens.fontFamilyMonospace, fontSize: tokens.fontSizeBase200 }, + sampleRow: { display: 'flex', alignItems: 'baseline', flexWrap: 'wrap', gap: '4px 10px' }, + detected: { + display: 'flex', + flexDirection: 'column', + gap: '10px', + padding: '12px', + borderRadius: tokens.borderRadiusMedium, + border: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: tokens.colorNeutralBackground2, + }, + facts: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '8px 20px', margin: 0 }, + fact: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0, margin: 0 }, + factValue: { margin: 0, minWidth: 0, overflowWrap: 'anywhere' }, + queryPreview: { + margin: 0, + padding: '8px 10px', + borderRadius: tokens.borderRadiusSmall, + backgroundColor: tokens.colorNeutralBackground1, + border: `1px solid ${tokens.colorNeutralStroke2}`, + whiteSpace: 'pre-wrap', + overflowWrap: 'anywhere', + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase200, + color: tokens.colorNeutralForeground1, + }, + detectedActions: { display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: '8px 16px' }, + commandSelect: { minWidth: '200px' }, + issue: { display: 'flex', alignItems: 'flex-start', gap: '6px', color: tokens.colorPaletteRedForeground1 }, + issueIcon: { flexShrink: 0, marginTop: '2px' }, + // Fluent's Table is `table-layout: fixed`: every column that must not take the leftover space + // needs an explicit width (copied from LocalQuickStart's settings table). + colLabel: { width: '200px', '@media (max-width: 560px)': { width: '110px' } }, + colAction: { width: '48px' }, + valueCell: { minWidth: 0, overflowWrap: 'anywhere' }, + rowAction: { display: 'flex', justifyContent: 'flex-end' }, + editorRow: { borderBottom: 'none' }, + editorCell: { display: 'table-cell', height: 'auto', padding: 0 }, + editorBody: { + display: 'flex', + flexDirection: 'column', + gap: '12px', + padding: '10px 12px 14px 208px', + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + '@media (max-width: 560px)': { paddingLeft: '12px' }, + }, + nameFields: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'flex-start', + gap: '12px', + '> *': { flexGrow: 1, flexBasis: '200px', minWidth: 0 }, + '& .fui-Input': { minWidth: 0 }, + }, + summaryBar: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: '4px 8px', + marginBottom: '8px', + color: tokens.colorNeutralForeground2, + }, +}); + +function describeIssue(issue: InputIssue): string { + return issue.line !== undefined && issue.column !== undefined + ? l10n.t('Line {0}, column {1}: {2}', String(issue.line), String(issue.column), issue.message) + : issue.message; +} + +function namespaceOf(databaseName?: string, collectionName?: string): string | undefined { + if (databaseName && collectionName) return `${databaseName}.${collectionName}`; + return collectionName || databaseName || undefined; +} + +function clusterTypeLabel(value: StandaloneInput['clusterType']): string { + switch (value) { + case 'azure-documentdb': + return l10n.t('Azure DocumentDB'); + case 'documentdb': + return l10n.t('DocumentDB'); + default: + return l10n.t('Other'); + } +} + +function CodeSnippet({ + snippet, + onCopy, +}: { + snippet: ShellSnippet; + onCopy: (snippet: ShellSnippet) => Promise; +}): JSX.Element { + const styles = useStyles(); + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return undefined; + const timer = window.setTimeout(() => setCopied(false), 2000); + return () => window.clearTimeout(timer); + }, [copied]); + const label = l10n.t('Copy the {0} command', snippet.label); + return ( +
+ + {snippet.label} + +
+                {snippet.code}
+            
+ +
+ ); +} + +function Analyze({ + input, + onKindChange, +}: { + input: StandaloneInput; + onKindChange: (kind: AnalysisKind) => void; +}): JSX.Element { + const client = useTrpcClient(); + const [pipeline, setPipeline] = useState({ kind: 'idle' }); + const dispatch = useCallback((transition) => setPipeline(transition), []); + useEffect(() => onKindChange(pipeline.kind), [pipeline.kind, onKindChange]); + const source = useMemo( + () => ({ + pipeline, + dispatch, + identity: { databaseName: input.databaseName, collectionName: input.collectionName }, + actionsEnabled: false, + isQueryLoading: false, + autoStartStage3: true, + commandType: input.commandType as CommandType, + getStage1: () => client.queryInsightsStandalone.getStage1.query(input), + getStage2: () => client.queryInsightsStandalone.getStage2.query(input), + streamStage3: (requestKey, callbacks, modelId) => + client.queryInsightsStandalone.streamStage3.subscribe( + { ...input, modelId: modelId ?? input.modelId, requestKey }, + callbacks, + ), + executeAction: async () => ({ + success: false, + message: l10n.t('Index actions are disabled in the explorer.'), + }), + viewRawExplain: async (): Promise => { + await client.queryInsightsStandalone.openRawExplain.mutate(input); + }, + }), + [client, input, pipeline, dispatch], + ); + return ( + // The wizard body is the only scroll container: this host flattens the tab's own scroll area. +
+ + + +
+ ); +} + +export function QueryInsightsStandalone(): JSX.Element { + const { designPreview } = useConfiguration(); + return designPreview === 'tab-model-selection' ? : ; +} + +function QueryInsightsWizard(): JSX.Element { + const styles = useStyles(); + const prefill = useConfiguration(); + const client = useTrpcClient(); + const noteId = useId(); + // Welcome only explains how to get an explain output; skip it when one is already supplied. + const [step, setStep] = useState(() => (prefill.explain?.trim() ? 'configure' : 'welcome')); + const [form, setForm] = useState(() => ({ + explain: prefill.explain ?? '', + query: prefill.query ?? '', + commandType: prefill.commandType ?? 'auto', + collectionStats: prefill.collectionStats ?? '', + indexes: prefill.indexes ?? '', + databaseName: prefill.databaseName ?? '', + collectionName: prefill.collectionName ?? '', + clusterType: prefill.clusterType ?? 'azure-documentdb', + })); + const [inspection, setInspection] = useState(); + const [inspecting, setInspecting] = useState(false); + const [inspectFailure, setInspectFailure] = useState(); + const [submitting, setSubmitting] = useState(false); + const [selectedModel, setSelectedModel] = useState(); + const [openRows, setOpenRows] = useState>>({}); + const [editingQuery, setEditingQuery] = useState(() => !!prefill.query?.trim()); + const [analysisInput, setAnalysisInput] = useState(); + const [runKey, setRunKey] = useState(0); + const [analysisKind, setAnalysisKind] = useState('idle'); + const [announcement, setAnnouncement] = useState<{ key: number; message: string }>({ key: 0, message: '' }); + const announce = useCallback( + (message: string): void => setAnnouncement((previous) => ({ key: previous.key + 1, message })), + [], + ); + + const update = (key: K, value: StandaloneInput[K]): void => { + setForm((previous) => ({ ...previous, [key]: value })); + }; + + // Live validation. Debounced, and every newer edit aborts the older request. + const { explain, query, commandType, collectionStats, indexes, clusterType } = form; + const inspectionInput = useMemo( + () => ({ + explain, + query, + commandType, + collectionStats, + indexes, + clusterType, + databaseName: '', + collectionName: '', + }), + [explain, query, commandType, collectionStats, indexes, clusterType], + ); + const latestInspection = useRef(0); + useEffect(() => { + const controller = new AbortController(); + const request = ++latestInspection.current; + setInspecting(true); + const timer = window.setTimeout(() => { + client.queryInsightsStandalone.inspectInput + .query(inspectionInput, { signal: controller.signal }) + .then((result) => { + if (request === latestInspection.current) { + setInspection(result); + setInspectFailure(undefined); + } + }) + .catch((cause: unknown) => { + if (!controller.signal.aborted && request === latestInspection.current) { + setInspectFailure(cause instanceof Error ? cause.message : String(cause)); + } + }) + .finally(() => { + if (request === latestInspection.current) setInspecting(false); + }); + }, 250); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [client, inspectionInput]); + + const explainInfo = inspection?.explain?.ok ? inspection.explain : undefined; + const explainIssue = inspection?.explain && !inspection.explain.ok ? inspection.explain : undefined; + const effectiveDatabase = form.databaseName.trim() || explainInfo?.databaseName || ''; + const effectiveCollection = form.collectionName.trim() || explainInfo?.collectionName || ''; + const effectiveCommand = (inspection?.commandType ?? explainInfo?.detectedCommandType ?? 'find') as CommandType; + const pending = inspecting || submitting; + + let blockedReason: string | undefined; + if (!form.explain.trim()) blockedReason = l10n.t('Paste the explain output to continue.'); + else if (explainIssue) blockedReason = l10n.t('Fix the explain output to continue.'); + else if (inspection?.queryIssue) blockedReason = l10n.t('Fix the query to continue.'); + else if (inspection?.collectionStats?.ok === false) + blockedReason = l10n.t('Fix the collection statistics to continue.'); + else if (inspection?.indexes?.ok === false) blockedReason = l10n.t('Fix the indexes to continue.'); + else if (!selectedModel) blockedReason = l10n.t('Choose a GitHub Copilot model to continue.'); + const canAnalyze = !blockedReason && !pending && inspection?.ready === true; + + const copySnippet = useCallback( + async (snippet: ShellSnippet): Promise => { + try { + await client.common.copyToClipboard.mutate({ text: snippet.code }); + announce(l10n.t('Copied the {0} command.', snippet.label)); + return true; + } catch { + announce(l10n.t('Could not copy the {0} command.', snippet.label)); + return false; + } + }, + [client, announce], + ); + + const loadSample = (command: SampleCommand): void => { + setForm((previous) => ({ ...previous, explain: samplePlan(command), query: '', commandType: 'auto' })); + setEditingQuery(false); + announce(l10n.t('Loaded the {0} sample plan.', command)); + }; + + const analyze = async (): Promise => { + setSubmitting(true); + try { + const result = await client.queryInsightsStandalone.inspectInput.query(inspectionInput); + setInspection(result); + if (!result.ready || !result.commandType || result.query === undefined) return; + setAnalysisInput({ + ...form, + query: result.query, + commandType: result.commandType, + databaseName: effectiveDatabase, + collectionName: effectiveCollection, + modelId: selectedModel?.id, + }); + setRunKey((value) => value + 1); + setStep('analyze'); + } catch (cause) { + setInspectFailure(cause instanceof Error ? cause.message : String(cause)); + } finally { + setSubmitting(false); + } + }; + + const toggleRow = (row: OptionalRow): void => setOpenRows((previous) => ({ ...previous, [row]: !previous[row] })); + + // ---- chrome ----------------------------------------------------------------------------- + + const header = ( + } + title={l10n.t('Query Insights Explorer')} + subtitle={l10n.t('Analyze a query plan with GitHub Copilot, without connecting to a database.')} + action={} + /> + ); + + // ---- Welcome ---------------------------------------------------------------------------- + + const needs: readonly { label: string; detail: string; required: boolean; provided: boolean }[] = [ + { + label: l10n.t('Explain output'), + detail: l10n.t( + 'The result of explain("executionStats"). Every rating and the plan diagram are based on it.', + ), + required: true, + provided: !!prefill.explain?.trim(), + }, + { + label: l10n.t('Collection statistics'), + detail: l10n.t('The document count shows how much of the collection the query reads.'), + required: false, + provided: !!prefill.collectionStats?.trim(), + }, + { + label: l10n.t('Indexes'), + detail: l10n.t('Lets GitHub Copilot reuse an existing index instead of recommending a duplicate.'), + required: false, + provided: !!prefill.indexes?.trim(), + }, + ]; + + const welcome = ( +
+
+ + {l10n.t("What you'll need")} + +
    + {needs.map((need, index) => ( +
  1. + {/* Fluent's own default: filled, brand, circular (as in LocalQuickStart). */} + +
    + + {need.label} + {need.required ? ( + + {l10n.t('Required')} + + ) : ( + + {l10n.t('Optional')} + + )} + + + {need.detail} + + {need.provided && ( + + + )} +
    +
  2. + ))} +
+
+
+ + {l10n.t('Get the explain output')} + + + {l10n.t( + 'Run one of these in the DocumentDB shell or mongosh with your own collection and filter, then copy the whole result.', + )} + +
+ {explainSnippets.map((snippet) => ( + + ))} + + {l10n.t('Optional context')} + + {contextSnippets.map((snippet) => ( + + ))} +
+
+ } title={l10n.t('What is sent to GitHub Copilot')}> + + {l10n.t( + 'When you analyze, the explain plan, query, statistics and indexes you provide are sent to the selected GitHub Copilot model. Values in filters are included as-is, so remove anything sensitive first.', + )} + + {l10n.t('Recommendations are copy-only; the explorer never changes a database.')} + +
+ ); + + // ---- Configure -------------------------------------------------------------------------- + + const issueLine = (issue: InputIssue): JSX.Element => ( + + + {describeIssue(issue)} + + ); + + let explainState: 'none' | 'error' | 'success' = 'none'; + let explainMessage: string | undefined; + if (form.explain.trim() && !inspecting && explainIssue) { + explainState = 'error'; + explainMessage = describeIssue(explainIssue); + } else if (form.explain.trim() && !inspecting && explainInfo) { + explainState = 'success'; + explainMessage = l10n.t('Explain output parsed.'); + } + const commandWasDetected = form.commandType === 'auto'; + + const explainSection = ( +
+ + {l10n.t('Explain output')} + + {l10n.t('Required')} + + + {/* The section heading names the field; the editor carries its own accessible name. */} + + {l10n.t('Checking…')} + + ) : ( + explainMessage + ) + } + validationMessageIcon={inspecting && form.explain.trim() ? null : undefined} + > + update('explain', value)} + /> + +
+ + {l10n.t('Load a sample plan:')} + + {(['find', 'aggregate', 'count'] as const).map((command) => ( + loadSample(command)} + > + {command} + + ))} +
+ +
+
+
+
+ + {l10n.t('Command')} + +
+
+ + {effectiveCommand} + {' '} + + {commandWasDetected ? l10n.t('detected') : l10n.t('selected')} + +
+
+ {!!explainInfo?.planStages.length && ( +
+
+ + {l10n.t('Plan')} + +
+
+ + {explainInfo.planStages.join(' → ')} + +
+
+ )} +
+ {inspection?.query !== undefined && ( + <> + + {inspection.queryIsExtracted ? l10n.t('Extracted query') : l10n.t('Your query')} + +
+                                {inspection.query}
+                            
+ + )} + {inspection?.queryIssue && !editingQuery && issueLine(inspection.queryIssue)} +
+ + + + +
+ +
+ + update('query', value)} + /> + +
+
+
+
+
+ ); + + const statsInfo = inspection?.collectionStats; + const indexInfo = inspection?.indexes; + const notProvided = {l10n.t('Not provided')}; + const rowAction = (row: OptionalRow, label: string, openLabel: string): JSX.Element => ( + + + + + {analysisInput.commandType} + + {[ + + {namespaceOf(analysisInput.databaseName, analysisInput.collectionName) ?? + l10n.t('Namespace not provided')} + , + + {clusterTypeLabel(analysisInput.clusterType)} + , + selectedModel && ( + + {selectedModel.name} + + ), + ] + .filter(Boolean) + .map((fact, index) => ( + + · + {fact} + + ))} +
+ ); + + // ---- footer ----------------------------------------------------------------------------- + + let footer: JSX.Element; + if (step === 'welcome') { + footer = ( + + + + ); + } else if (step === 'configure') { + footer = ( + + {[ + blockedReason ?? + (selectedModel + ? l10n.t('Analyze query sends your inputs to {0}.', selectedModel.name) + : l10n.t('Analyze query sends your inputs to the selected model.')), + selectedModel && + (selectedModel.isUtility + ? l10n.t('No additional cost for most GitHub Copilot subscribers.') + : l10n.t('The selected model may use premium requests.')), + l10n.t('AI responses may be inaccurate'), + ] + .filter(Boolean) + .map((part, index) => ( + + {index > 0 && · } + {part} + + ))} + + } + > + {/* Configuration first, then the action (as in the VS Code Chat input). */} +
+ +
+ + +
+ ); + } else { + footer = ( + // Reconfigure lives above the results; the footer only explains. + + ); + } + + return ( + <> + {/* Outside the wizard so they stay mounted across steps (see LocalQuickStart). */} + 0} + message={announcement.message} + /> + + + + + + { + if (!submitting && value !== 'analyze') setStep(value as StepId); + }} + contentMaxWidth={step === 'analyze' ? 'none' : '760px'} + headerBehavior="sticky-navigation" + stepsLocked={submitting} + stepsAriaLabel={l10n.t('Query Insights Explorer steps')} + overflowAriaLabel={(count) => l10n.t('{0} more steps', String(count))} + header={header} + footer={footer} + > + + {welcome} + + + {configure} + + + {summaryBar} + {step === 'analyze' && analysisInput && ( + + )} + + + + ); +} diff --git a/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandalone.scss b/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandalone.scss new file mode 100644 index 000000000..3540297c9 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandalone.scss @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Like LocalQuickStart: the wizard owns its insets, so drop VS Code's default body gutter and let + * the footer's border and shadow span edge-to-edge. Local to this view's bundle. */ +body { + padding: 0; +} + +/* The reused Query Insights layout is built for a fixed-height tab with its own scroll area + * (`.container` clips, `.contentArea` scrolls). Inside the wizard the body already scrolls, so a + * second scrollbar would nest inside the page. Flatten both here only; the tab is unaffected. */ +.queryInsightsExplorer-analysis { + min-width: 0; + + > .container { + height: auto; + margin-inline: 0; + overflow: visible; + } + + > .container > .contentArea { + flex: none; + overflow: visible; + padding-block: 0; + padding-inline: 0; + } +} diff --git a/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneController.ts b/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneController.ts new file mode 100644 index 000000000..50e3fb614 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneController.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { API } from '../../../DocumentDBExperiences'; +import { openAppWebview, type AppWebviewController } from '../../_integration/openAppWebview'; +import { type StandaloneRouterContext } from './queryInsightsStandaloneRouter'; +import { type QueryInsightsPrefill } from './types'; + +export function openQueryInsightsStandalone( + prefill: QueryInsightsPrefill = {}, +): AppWebviewController { + const context: StandaloneRouterContext = { + dbExperience: API.DocumentDB, + webviewName: 'queryInsightsStandalone', + prefill, + }; + return openAppWebview({ + webviewName: 'queryInsightsStandalone', + panelViewType: 'vscode-documentdb-query-insights-standalone', + title: vscode.l10n.t('Query Insights (POC)'), + config: prefill, + context, + }); +} diff --git a/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneInput.ts b/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneInput.ts new file mode 100644 index 000000000..57ba946b5 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneInput.ts @@ -0,0 +1,309 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ParseMode, parse as parseShellBSON } from '@mongodb-js/shell-bson-parser'; +import { EJSON } from 'bson'; +import * as vscode from 'vscode'; +import { z } from 'zod'; +import { CommandType, type QueryObject } from '../../../commands/llmEnhancedCommands/indexAdvisorCommands'; +import { type CollectionStats, type IndexStats } from '../../../documentdb/LlmEnhancedFeatureApis'; +import { getInitialExplainPlan } from '../../../documentdb/queryInsights/analysisProfiles'; +import { fixupDocumentDbExplain } from '../../../documentdb/utils/fixupDocumentDbExplain'; +import { type ClusterMetadata } from '../../../documentdb/utils/getClusterMetadata'; +import { toFilterQueryObj } from '../../../documentdb/utils/toFilterQuery'; +import { type StandaloneInput } from './types'; + +export const queryInsightsPrefillSchema = z.object({ + explain: z.string().optional(), + query: z.string().optional(), + commandType: z.enum(['find', 'aggregate', 'count']).optional(), + collectionStats: z.string().optional(), + indexes: z.string().optional(), + databaseName: z.string().optional(), + collectionName: z.string().optional(), + clusterType: z.enum(['azure-documentdb', 'documentdb', 'other']).optional(), +}); + +export const standaloneInputSchema = z.object({ + explain: z.string().min(1), + query: z.string(), + commandType: z.enum(['auto', 'find', 'aggregate', 'count']), + collectionStats: z.string(), + indexes: z.string(), + databaseName: z.string(), + collectionName: z.string(), + clusterType: z.enum(['azure-documentdb', 'documentdb', 'other']), + modelId: z.string().optional(), +}); + +function parseInput(text: string): unknown { + try { + return EJSON.parse(text, { relaxed: true }) as unknown; + } catch { + return EJSON.parse(EJSON.stringify(toFilterQueryObj(text)), { relaxed: true }) as unknown; + } +} + +function object(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +export function parseStandaloneInput(input: StandaloneInput): { + explain: Record; + commandType: CommandType; + query: string; + queryObject?: QueryObject; + collectionStats?: CollectionStats; + indexStats?: IndexStats[]; + totalDocs?: number; + filter?: Record; + clusterInfo: ClusterMetadata; +} { + const explain = object(parseInput(input.explain)); + if ( + !explain || + (!object(explain.queryPlanner) && !object(explain.executionStats) && !Array.isArray(explain.stages)) + ) { + throw new Error(vscode.l10n.t('Paste an explain plan containing queryPlanner, executionStats, or stages.')); + } + const clusterInfo: ClusterMetadata = { platform: input.clusterType }; + if (input.clusterType === 'azure-documentdb') { + clusterInfo.domainInfo_isAzure = 'true'; + clusterInfo.domainInfo_api = 'vCore'; + fixupDocumentDbExplain(explain, clusterInfo); + const cursor = getInitialExplainPlan(explain, 'aggregate'); + if (cursor !== explain) fixupDocumentDbExplain(cursor, clusterInfo); + } + const command = object(explain.command) ?? {}; + const planner = object(getInitialExplainPlan(explain, 'aggregate').queryPlanner) ?? {}; + let detected = CommandType.Find; + if ('aggregate' in command || Array.isArray(explain.stages)) detected = CommandType.Aggregate; + else if ('count' in command) detected = CommandType.Count; + const commandType = input.commandType === 'auto' ? detected : (input.commandType as CommandType); + const inferredPipeline: Record[] = Array.isArray(explain.stages) + ? (explain.stages as unknown[]).slice(1).flatMap((stage) => { + const operators = Object.fromEntries( + Object.entries(object(stage) ?? {}).filter(([key]) => key.startsWith('$')), + ); + return Object.keys(operators).length ? [operators] : []; + }) + : []; + const initialFilter = object(planner.parsedQuery); + if (initialFilter && Object.keys(initialFilter).length) inferredPipeline.unshift({ $match: initialFilter }); + const extracted = + commandType === CommandType.Aggregate + ? (command.pipeline ?? inferredPipeline) + : (command.filter ?? command.query ?? planner.parsedQuery ?? {}); + const query = input.query.trim() || EJSON.stringify(extracted, { relaxed: true }, 2); + const parsedQuery = parseInput(query); + const queryRecord = object(parsedQuery); + if (commandType === CommandType.Aggregate ? !Array.isArray(parsedQuery) : !queryRecord) { + throw new Error(vscode.l10n.t('Use a JSON array for an aggregate pipeline, or a JSON object for find/count.')); + } + const isQueryObject = + commandType === CommandType.Find && + queryRecord !== undefined && + ['filter', 'sort', 'projection', 'skip', 'limit'].some((key) => key in queryRecord); + const queryObject = + commandType === CommandType.Find + ? isQueryObject + ? (queryRecord as QueryObject) + : { + filter: queryRecord, + sort: object(command.sort), + projection: object(command.projection), + } + : undefined; + const stats = input.collectionStats.trim() ? object(parseInput(input.collectionStats)) : undefined; + if (input.collectionStats.trim() && !stats) { + throw new Error(vscode.l10n.t('Collection statistics must be a JSON object.')); + } + const indexes = input.indexes.trim() ? parseInput(input.indexes) : undefined; + if (indexes !== undefined && (!Array.isArray(indexes) || indexes.some((item: unknown) => !object(item)))) { + throw new Error(vscode.l10n.t('Indexes must be a JSON array of index objects.')); + } + let filter = queryObject?.filter; + if (commandType === CommandType.Count) filter = queryRecord; + else if (commandType === CommandType.Aggregate) { + filter = object(object(Array.isArray(parsedQuery) ? parsedQuery[0] : undefined)?.$match) ?? initialFilter; + } + return { + explain, + commandType, + query, + queryObject, + clusterInfo, + // These supplied objects are serialized into the prompt, never used for database actions. + collectionStats: stats as unknown as CollectionStats | undefined, + indexStats: indexes as IndexStats[] | undefined, + totalDocs: typeof stats?.count === 'number' && stats.count >= 0 ? stats.count : undefined, + filter, + }; +} + +/** Where a supplied text failed to parse. Line and column are 1-based. */ +export interface InputIssue { + message: string; + line?: number; + column?: number; +} + +export type ExplainInspection = + | { + ok: true; + detectedCommandType: CommandType; + databaseName?: string; + collectionName?: string; + /** Winning-plan stages in execution order (leaf first), then later pipeline stages. */ + planStages: string[]; + } + | ({ ok: false } & InputIssue); + +/** Non-throwing, per-field validation that the Configure step shows while the user types. */ +export interface StandaloneInspection { + explain?: ExplainInspection; + commandType?: CommandType; + /** The query that Analyze will send: the user's override, or the one extracted from the plan. */ + query?: string; + queryIsExtracted?: boolean; + queryIssue?: InputIssue; + collectionStats?: { ok: true; documentCount?: number } | ({ ok: false } & InputIssue); + indexes?: { ok: true; names: string[] } | ({ ok: false } & InputIssue); + /** True when Analyze can run with these inputs. */ + ready: boolean; +} + +function parseWithLocation(text: string): { value: unknown } | { issue: InputIssue } { + try { + return { value: EJSON.parse(text, { relaxed: true }) as unknown }; + } catch { + try { + const value = parseShellBSON(text, { mode: ParseMode.Loose }) as unknown; + return { value: EJSON.parse(EJSON.stringify(value), { relaxed: true }) as unknown }; + } catch (cause) { + const loc = (cause as { loc?: { line?: number; column?: number } }).loc; + const raw = cause instanceof Error ? cause.message : String(cause); + // The shell parser wraps the text in "(\n…\n)": its line is one too high and its column 0-based. + const reason = raw.replace(/\s*\(\d+:\d+\)[\s\S]*$/, '').trim() || raw; + return typeof loc?.line === 'number' && typeof loc.column === 'number' + ? { issue: { message: reason, line: Math.max(1, loc.line - 1), column: loc.column + 1 } } + : { issue: { message: reason } }; + } + } +} + +function planStageChain(plan: Record | undefined): string[] { + const chain: string[] = []; + let node = plan; + while (node && chain.length < 8) { + if (typeof node.stage === 'string') chain.push(node.stage); + node = object(node.inputStage) ?? object(Array.isArray(node.inputStages) ? node.inputStages[0] : undefined); + } + return chain.reverse(); +} + +function inspectExplain(explain: Record): ExplainInspection { + const command = object(explain.command) ?? {}; + const initial = getInitialExplainPlan(explain, 'aggregate'); + const planner = object(initial.queryPlanner) ?? object(explain.queryPlanner) ?? {}; + let detectedCommandType = CommandType.Find; + if ('aggregate' in command || Array.isArray(explain.stages)) detectedCommandType = CommandType.Aggregate; + else if ('count' in command) detectedCommandType = CommandType.Count; + let databaseName: string | undefined; + let collectionName: string | undefined; + if (typeof planner.namespace === 'string' && planner.namespace.includes('.')) { + const dot = planner.namespace.indexOf('.'); + databaseName = planner.namespace.slice(0, dot); + collectionName = planner.namespace.slice(dot + 1); + } else { + const target = command.find ?? command.aggregate ?? command.count; + collectionName = typeof target === 'string' ? target : undefined; + databaseName = typeof command.$db === 'string' ? command.$db : undefined; + } + const laterStages = Array.isArray(explain.stages) + ? (explain.stages as unknown[]) + .slice(1) + .flatMap((stage) => Object.keys(object(stage) ?? {}).filter((key) => key.startsWith('$'))) + : []; + return { + ok: true, + detectedCommandType, + databaseName, + collectionName, + planStages: [ + ...planStageChain(object(planner.winningPlan) ?? object(object(initial.executionStats)?.executionStages)), + ...laterStages, + ], + }; +} + +export function inspectStandaloneInput(input: StandaloneInput): StandaloneInspection { + const result: StandaloneInspection = { ready: false }; + if (input.collectionStats.trim()) { + const parsed = parseWithLocation(input.collectionStats); + const stats = 'value' in parsed ? object(parsed.value) : undefined; + result.collectionStats = + 'issue' in parsed + ? { ok: false, ...parsed.issue } + : stats + ? { ok: true, documentCount: typeof stats.count === 'number' ? stats.count : undefined } + : { ok: false, message: vscode.l10n.t('Collection statistics must be a JSON object.') }; + } + if (input.indexes.trim()) { + const parsed = parseWithLocation(input.indexes); + const list = + 'value' in parsed && Array.isArray(parsed.value) && parsed.value.every((item: unknown) => object(item)) + ? (parsed.value as Record[]) + : undefined; + result.indexes = + 'issue' in parsed + ? { ok: false, ...parsed.issue } + : list + ? { + ok: true, + names: list.map((item) => (typeof item.name === 'string' ? item.name : '')).filter(Boolean), + } + : { ok: false, message: vscode.l10n.t('Indexes must be a JSON array of index objects.') }; + } + if (!input.explain.trim()) return result; + const parsedExplain = parseWithLocation(input.explain); + if ('issue' in parsedExplain) { + result.explain = { ok: false, ...parsedExplain.issue }; + return result; + } + const explain = object(parsedExplain.value); + if ( + !explain || + (!object(explain.queryPlanner) && !object(explain.executionStats) && !Array.isArray(explain.stages)) + ) { + result.explain = { + ok: false, + message: vscode.l10n.t('Paste an explain plan containing queryPlanner, executionStats, or stages.'), + }; + return result; + } + result.explain = inspectExplain(explain); + if (input.query.trim()) { + const parsedQuery = parseWithLocation(input.query); + if ('issue' in parsedQuery) { + result.queryIssue = parsedQuery.issue; + return result; + } + } + try { + // Optional inputs are validated above, so only explain/query problems can surface here. + const parsed = parseStandaloneInput({ ...input, collectionStats: '', indexes: '' }); + result.commandType = parsed.commandType; + result.query = parsed.query; + result.queryIsExtracted = !input.query.trim(); + } catch (cause) { + result.queryIssue = { message: cause instanceof Error ? cause.message : String(cause) }; + return result; + } + result.ready = result.collectionStats?.ok !== false && result.indexes?.ok !== false; + return result; +} diff --git a/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneInspection.test.ts b/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneInspection.test.ts new file mode 100644 index 000000000..711cf6a15 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneInspection.test.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, vi } from 'vitest'; +import { inspectStandaloneInput } from './queryInsightsStandaloneInput'; +import { samplePlan } from './samplePlans'; +import { type StandaloneInput } from './types'; + +vi.mock('vscode', () => ({ l10n: { t: (text: string): string => text } })); +vi.mock('../../../commands/llmEnhancedCommands/indexAdvisorCommands', () => ({ + CommandType: { Find: 'find', Aggregate: 'aggregate', Count: 'count' }, +})); + +const blank: StandaloneInput = { + explain: '', + query: '', + commandType: 'auto', + collectionStats: '', + indexes: '', + databaseName: '', + collectionName: '', + clusterType: 'documentdb', +}; + +describe('Configure-step inspection', () => { + it('is not ready, and reports nothing, before an explain plan is pasted', () => { + expect(inspectStandaloneInput(blank)).toEqual({ ready: false }); + }); + + it.each([ + ['find', 'find', ['IXSCAN', 'FETCH', 'SORT']], + ['aggregate', 'aggregate', ['COLLSCAN', '$group', '$sort']], + ['count', 'count', ['COUNT_SCAN', 'COUNT']], + ] as const)('parses the %s sample plan, its namespace, plan and query', (sample, command, stages) => { + const result = inspectStandaloneInput({ ...blank, explain: samplePlan(sample) }); + expect(result.ready).toBe(true); + expect(result.explain).toEqual({ + ok: true, + detectedCommandType: command, + databaseName: 'shop', + collectionName: 'orders', + planStages: stages, + }); + expect(result.commandType).toBe(command); + expect(result.queryIsExtracted).toBe(true); + expect(result.query).toContain(sample === 'count' ? 'pending' : 'shipped'); + }); + + it('reports the 1-based line and column of a syntax error', () => { + const result = inspectStandaloneInput({ ...blank, explain: '{\n "queryPlanner": {},\n "executionStats": }' }); + expect(result.ready).toBe(false); + expect(result.explain).toMatchObject({ ok: false, line: 3, column: 21 }); + expect(result.explain?.ok === false && result.explain.message).not.toMatch(/\(\d+:\d+\)/); + }); + + it('rejects parsable JSON that is not an explain plan', () => { + expect(inspectStandaloneInput({ ...blank, explain: '{"ok": 1}' }).explain).toMatchObject({ ok: false }); + }); + + it('summarizes optional context and flags each invalid field on its own', () => { + const explain = samplePlan('find'); + const ok = inspectStandaloneInput({ + ...blank, + explain, + collectionStats: '{ count: 48210 }', + indexes: "[ { v: 2, key: { _id: 1 }, name: '_id_' }, { key: { status: 1 }, name: 'status_1' } ]", + }); + expect(ok.collectionStats).toEqual({ ok: true, documentCount: 48210 }); + expect(ok.indexes).toEqual({ ok: true, names: ['_id_', 'status_1'] }); + expect(ok.ready).toBe(true); + + const bad = inspectStandaloneInput({ ...blank, explain, collectionStats: '[]', indexes: '[{ name: }]' }); + expect(bad.collectionStats).toMatchObject({ + ok: false, + message: 'Collection statistics must be a JSON object.', + }); + expect(bad.indexes).toMatchObject({ ok: false, line: 1 }); + expect(bad.explain?.ok).toBe(true); + expect(bad.ready).toBe(false); + }); + + it('keeps a user query override and reports an invalid one without blocking explain detection', () => { + const explain = samplePlan('find'); + expect(inspectStandaloneInput({ ...blank, explain, query: '{ priority: 1 }' })).toMatchObject({ + ready: true, + queryIsExtracted: false, + }); + const invalid = inspectStandaloneInput({ ...blank, explain, query: '{ priority: }' }); + expect(invalid.queryIssue).toMatchObject({ line: 1 }); + expect(invalid.explain?.ok).toBe(true); + expect(invalid.ready).toBe(false); + expect( + inspectStandaloneInput({ ...blank, explain, commandType: 'aggregate', query: '{}' }).queryIssue, + ).toBeDefined(); + }); +}); diff --git a/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneRouter.test.ts b/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneRouter.test.ts new file mode 100644 index 000000000..ba071173f --- /dev/null +++ b/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneRouter.test.ts @@ -0,0 +1,273 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type QueryInsightsStreamEvent } from '../collectionView/types/queryInsightsStream'; +import { parseStandaloneInput } from './queryInsightsStandaloneInput'; +import { queryInsightsStandaloneRouter, type StandaloneRouterContext } from './queryInsightsStandaloneRouter'; +import { type StandaloneInput } from './types'; + +const mocks = vi.hoisted(() => ({ stream: vi.fn(), executeCommand: vi.fn(), openDocument: vi.fn() })); +vi.mock('vscode', () => ({ + env: { language: 'en-US' }, + l10n: { t: (text: string): string => text }, + commands: { executeCommand: mocks.executeCommand }, +})); +vi.mock('../../../commands/llmEnhancedCommands/indexAdvisorCommands', () => ({ + CommandType: { Find: 'find', Aggregate: 'aggregate', Count: 'count' }, +})); +vi.mock('../../../services/ai/QueryInsightsAIService', () => ({ + QueryInsightsAIService: class { + getOptimizationRecommendationsStreaming = mocks.stream; + }, +})); +vi.mock('../../../utils/readOnlyJsonDocumentProvider', () => ({ + readOnlyJsonDocumentProvider: { openDocument: mocks.openDocument }, +})); +vi.mock('../../_integration/trpc', async () => { + const { initTRPC } = await import('@trpc/server'); + const trpc = initTRPC.create(); + return { router: trpc.router, publicProcedure: trpc.procedure, publicProcedureWithTelemetry: trpc.procedure }; +}); + +const explain = { + queryPlanner: { + namespace: 'db.items', + winningPlan: { stage: 'COLLSCAN', direction: 'forward' }, + parsedQuery: { status: 'active' }, + }, + executionStats: { + executionSuccess: true, + executionTimeMillis: 12, + nReturned: 2, + totalDocsExamined: 100, + totalKeysExamined: 0, + executionStages: { stage: 'COLLSCAN', nReturned: 2, docsExamined: 100 }, + }, + command: { find: 'items', filter: { status: 'active' }, sort: { date: -1 } }, +}; +const input: StandaloneInput = { + explain: JSON.stringify(explain), + query: '', + commandType: 'auto', + collectionStats: '{"count":200}', + indexes: '[{"name":"status_1","key":{"status":1}}]', + databaseName: 'db', + collectionName: 'items', + clusterType: 'documentdb', + modelId: 'picked-model', +}; + +describe('Connectionless Query Insights', () => { + beforeEach(() => vi.clearAllMocks()); + + it('extracts a find query, supplied stats and indexes without a session', () => { + const parsed = parseStandaloneInput(input); + expect(parsed.commandType).toBe('find'); + expect(parsed.queryObject).toEqual({ filter: { status: 'active' }, sort: { date: -1 }, projection: undefined }); + expect(parsed.totalDocs).toBe(200); + expect(parsed.indexStats?.[0].name).toBe('status_1'); + }); + + it('accepts relaxed JSON and Extended JSON', () => { + const parsed = parseStandaloneInput({ + ...input, + query: "{status: 'active', id: {$oid: '507f1f77bcf86cd799439011'}}", + }); + expect(parsed.queryObject?.filter?.status).toBe('active'); + expect(String(parsed.queryObject?.filter?.id)).toBe('507f1f77bcf86cd799439011'); + }); + + it.each(['aggregate', 'count'] as const)('detects %s and allows explicit command overrides', (commandType) => { + const command = + commandType === 'aggregate' + ? { aggregate: 'items', pipeline: [{ $match: { status: 'active' } }] } + : { count: 'items', query: { status: 'active' } }; + const parsed = parseStandaloneInput({ ...input, explain: JSON.stringify({ ...explain, command }) }); + expect(parsed.commandType).toBe(commandType); + expect(parsed.queryObject).toBeUndefined(); + expect(parseStandaloneInput({ ...input, commandType: 'count' }).commandType).toBe('count'); + }); + + it('uses parsedQuery when the command is absent and preserves edited query objects', () => { + expect( + parseStandaloneInput({ ...input, explain: JSON.stringify({ queryPlanner: explain.queryPlanner }) }).filter, + ).toEqual({ status: 'active' }); + expect(parseStandaloneInput({ ...input, query: '{"filter":{"priority":1},"limit":5}' }).queryObject).toEqual({ + filter: { priority: 1 }, + limit: 5, + }); + }); + + it.each([ + { explain: '{}' }, + { explain: 'not JSON' }, + { collectionStats: '[]' }, + { indexes: '{}' }, + { indexes: '[1]' }, + { query: '[]' }, + { commandType: 'aggregate' as const, query: '{}' }, + ])('rejects invalid supplied input: %j', (change) => { + expect(() => parseStandaloneInput({ ...input, ...change })).toThrow(); + }); + + it('exposes prefill and the same Stage 2 metrics without requiring cluster/session ids', async () => { + const caller = queryInsightsStandaloneRouter.createCaller({ + prefill: { databaseName: 'db' }, + } as StandaloneRouterContext); + expect(await caller.getPrefill()).toEqual({ databaseName: 'db' }); + expect(await caller.validateInput(input)).toMatchObject({ commandType: 'find' }); + expect(await caller.inspectInput(input)).toMatchObject({ + ready: true, + commandType: 'find', + explain: { ok: true }, + }); + expect(await caller.inspectInput({ ...input, explain: '' })).toMatchObject({ ready: false }); + expect(await caller.getStage1(input)).toBeDefined(); + const stage2 = await caller.getStage2(input); + expect(stage2.executionTimeMs).toBe(12); + expect(stage2.documentsReturned).toBe(2); + expect(stage2.efficiencyAnalysis.selectivity).toBe(1); + }); + + it('opens the explorer through the same contributed command and acknowledges the mutation', async () => { + const caller = queryInsightsStandaloneRouter.createCaller({}); + expect(await caller.openExplorer({ databaseName: 'db' })).toBe(true); + expect(mocks.executeCommand).toHaveBeenCalledWith( + 'vscode-documentdb.command.queryInsights.exploreWithOwnInput', + { databaseName: 'db' }, + ); + }); + + it('opens supplied raw explain without using a Collection View session', async () => { + const caller = queryInsightsStandaloneRouter.createCaller({}); + expect(await caller.openRawExplain(input)).toBe(true); + expect(mocks.openDocument).toHaveBeenCalledWith( + 'Raw Explain Output', + expect.stringContaining('"queryPlanner"'), + ); + }); + + it.each(['azure-documentdb', 'documentdb', 'other'] as const)( + 'applies Azure DocumentDB explain fixups only for %s', + (clusterType) => { + const raw = { + queryPlanner: { winningPlan: { stage: 'SORT', inputStage: { stage: 'COLLSCAN' } } }, + executionStats: { + executionSuccess: true, + nReturned: 2, + totalDocsExamined: 2, + totalKeysExamined: 2, + executionStages: { + stage: 'SORT', + totalDocsExamined: 2, + totalKeysExamined: 2, + inputStage: { + stage: 'COLLSCAN', + nReturned: 2, + totalDocsExamined: 100, + totalKeysExamined: 100, + keysExamined: 100, + }, + }, + }, + }; + for (const aggregate of [false, true]) { + const pasted = aggregate + ? { stages: [{ $cursor: raw }, { $group: { _id: null }, nReturned: 1 }] } + : raw; + const parsed = parseStandaloneInput({ ...input, clusterType, explain: JSON.stringify(pasted) }); + const fixed = aggregate + ? (parsed.explain.stages as Array<{ $cursor: typeof raw }>)[0].$cursor + : (parsed.explain as unknown as typeof raw); + expect(fixed.executionStats.totalDocsExamined).toBe(clusterType === 'azure-documentdb' ? 100 : 2); + expect(fixed.executionStats.totalKeysExamined).toBe(clusterType === 'azure-documentdb' ? 0 : 2); + if (clusterType === 'azure-documentdb') { + expect(parsed.clusterInfo).toMatchObject({ domainInfo_isAzure: 'true', domainInfo_api: 'vCore' }); + } + } + }, + ); + + it('auto-detects a cursor-format aggregate without command metadata and uses its parsed filter', () => { + const parsed = parseStandaloneInput({ + ...input, + explain: JSON.stringify({ + stages: [ + { $cursor: { queryPlanner: explain.queryPlanner, executionStats: explain.executionStats } }, + { $group: { _id: null }, nReturned: 1 }, + ], + }), + }); + expect(parsed.commandType).toBe('aggregate'); + expect(parsed.filter).toEqual({ status: 'active' }); + expect(JSON.parse(parsed.query)).toEqual([{ $match: { status: 'active' } }, { $group: { _id: null } }]); + }); + + it('streams parser events, model metadata and supplied context; aborts on completion', async () => { + const json = JSON.stringify({ analysis: 'Analysis', educationalContent: 'Education', improvements: [] }); + mocks.stream.mockResolvedValue({ + fragments: (async function* (): AsyncGenerator { + yield json.slice(0, 30); + yield json.slice(30); + })(), + completion: Promise.resolve({ improvements: [], modelId: 'picked-model', modelDisplayName: 'Picked' }), + }); + const caller = queryInsightsStandaloneRouter.createCaller({}); + const stream = await caller.streamStage3({ ...input, requestKey: 'request-1' }); + const events: QueryInsightsStreamEvent[] = []; + for await (const event of stream) events.push(event); + expect(events[0]).toMatchObject({ type: 'status', phase: 'connecting' }); + expect(events.some((event) => event.type === 'summary')).toBe(true); + expect(events.at(-1)).toMatchObject({ type: 'complete', modelId: 'picked-model' }); + const args = mocks.stream.mock.calls[0] as unknown[]; + expect(args[0]).toBe(''); + expect(args[7]).toMatchObject({ + modelId: 'picked-model', + commandType: 'find', + collectionStats: { count: 200 }, + providedData: { clusterInfo: { platform: 'documentdb' } }, + }); + expect((args[5] as AbortSignal).aborted).toBe(true); + }); + + it('aborts on iterator return and does not start an already cancelled model request', async () => { + const controller = new AbortController(); + const caller = queryInsightsStandaloneRouter.createCaller({ signal: controller.signal }); + const stream = (await caller.streamStage3({ ...input, requestKey: 'cancel' }))[Symbol.asyncIterator](); + await stream.next(); + await stream.return?.(); + expect(mocks.stream).not.toHaveBeenCalled(); + controller.abort(); + const cancelled = (await caller.streamStage3({ ...input, requestKey: 'already-cancelled' }))[ + Symbol.asyncIterator + ](); + expect(await cancelled.next()).toEqual({ done: true, value: undefined }); + }); + + it.each(['count', 'aggregate'] as const)('sends the %s applicability summary to Stage 3', async (commandType) => { + const json = '{"analysis":"Analysis","educationalContent":"Education","improvements":[]}'; + mocks.stream.mockResolvedValue({ + fragments: (async function* (): AsyncGenerator { + yield json; + })(), + completion: Promise.resolve({ improvements: [] }), + }); + const stream = await queryInsightsStandaloneRouter.createCaller({}).streamStage3({ + ...input, + commandType, + query: commandType === 'aggregate' ? '[]' : '{}', + requestKey: 'scope', + }); + const events: QueryInsightsStreamEvent[] = []; + for await (const event of stream) events.push(event); + expect(events.at(-1)).toMatchObject({ type: 'complete' }); + const args = mocks.stream.mock.calls[0] as unknown[]; + expect(args[6]).toContain(`Analysis Profile: ${commandType.toUpperCase()}`); + if (commandType === 'count') expect(args[6]).toContain('Skipped rules: returned-document efficiency ratio'); + else expect(args[6]).toContain('NOT final pipeline output'); + expect(args[7]).toMatchObject({ commandType }); + }); +}); diff --git a/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneRouter.ts b/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneRouter.ts new file mode 100644 index 000000000..810c8ed84 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsStandalone/queryInsightsStandaloneRouter.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EJSON } from 'bson'; +import * as vscode from 'vscode'; +import { z } from 'zod'; +import { getInitialExplainPlan } from '../../../documentdb/queryInsights/analysisProfiles'; +import { analyzeExplainForStage2 } from '../../../documentdb/queryInsights/analyzeExplainForStage2'; +import { ExplainPlanAnalyzer } from '../../../documentdb/queryInsights/ExplainPlanAnalyzer'; +import { buildStaticAnalysisSummary } from '../../../documentdb/queryInsights/staticAnalysisSummary'; +import { StreamingResponseParser } from '../../../documentdb/queryInsights/streamingResponseParser'; +import { transformStage1Response } from '../../../documentdb/queryInsights/transformations'; +import { ext } from '../../../extensionVariables'; +import { QueryInsightsAIService } from '../../../services/ai/QueryInsightsAIService'; +import { readOnlyJsonDocumentProvider } from '../../../utils/readOnlyJsonDocumentProvider'; +import { type BaseRouterContext } from '../../_integration/appRouter'; +import { publicProcedure, publicProcedureWithTelemetry, router } from '../../_integration/trpc'; +import { type QueryInsightsStreamEvent } from '../collectionView/types/queryInsightsStream'; +import { + inspectStandaloneInput, + parseStandaloneInput, + queryInsightsPrefillSchema, + standaloneInputSchema, +} from './queryInsightsStandaloneInput'; +import { type QueryInsightsPrefill } from './types'; + +export type StandaloneRouterContext = BaseRouterContext & { prefill: QueryInsightsPrefill }; + +export const queryInsightsStandaloneRouter = router({ + openExplorer: publicProcedureWithTelemetry.input(queryInsightsPrefillSchema).mutation(async ({ input }) => { + await vscode.commands.executeCommand('vscode-documentdb.command.queryInsights.exploreWithOwnInput', input); + return true; + }), + getPrefill: publicProcedureWithTelemetry.query(({ ctx }) => (ctx as StandaloneRouterContext).prefill), + openRawExplain: publicProcedureWithTelemetry.input(standaloneInputSchema).mutation(async ({ input }) => { + const parsed = parseStandaloneInput(input); + await readOnlyJsonDocumentProvider.openDocument( + vscode.l10n.t('Raw Explain Output'), + EJSON.stringify(parsed.explain, { relaxed: true }, 4), + ); + return true; + }), + validateInput: publicProcedureWithTelemetry.input(standaloneInputSchema).query(({ input }) => { + const parsed = parseStandaloneInput(input); + return { commandType: parsed.commandType, query: parsed.query }; + }), + // Runs on every (debounced) edit in Configure, so it stays out of telemetry. + inspectInput: publicProcedure + .input(standaloneInputSchema.extend({ explain: z.string() })) + .query(({ input }) => inspectStandaloneInput(input)), + getStage1: publicProcedureWithTelemetry.input(standaloneInputSchema).query(({ input }) => { + const parsed = parseStandaloneInput(input); + return transformStage1Response( + ExplainPlanAnalyzer.analyzeQueryPlanner(getInitialExplainPlan(parsed.explain, parsed.commandType)), + 0, + ); + }), + getStage2: publicProcedureWithTelemetry.input(standaloneInputSchema).query(({ input }) => { + const parsed = parseStandaloneInput(input); + try { + return analyzeExplainForStage2(parsed.explain, parsed.totalDocs, parsed.filter, parsed.commandType); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ext.outputChannel.error( + vscode.l10n.t('[Query Insights Stage 2] Analysis of the execution plan failed: {error}', { + error: message, + }), + ); + if (error instanceof Error && error.stack) { + ext.outputChannel.error(error.stack); + } + throw error; + } + }), + streamStage3: publicProcedureWithTelemetry + .input(standaloneInputSchema.extend({ requestKey: z.string() })) + .subscription(async function* ({ ctx, input }): AsyncGenerator { + const controller = new AbortController(); + const signal = (ctx as StandaloneRouterContext).signal; + const abort = (): void => controller.abort(); + if (signal?.aborted) controller.abort(); + else signal?.addEventListener('abort', abort); + const start = Date.now(); + const elapsed = (): number => Date.now() - start; + try { + if (controller.signal.aborted) return; + yield { type: 'status', phase: 'connecting', elapsedMs: elapsed(), charsReceived: 0 }; + const parsed = parseStandaloneInput(input); + const stage2 = analyzeExplainForStage2( + parsed.explain, + parsed.totalDocs, + parsed.filter, + parsed.commandType, + ); + const handle = await new QueryInsightsAIService().getOptimizationRecommendationsStreaming( + '', + parsed.queryObject ?? parsed.query, + input.databaseName, + input.collectionName, + parsed.explain, + controller.signal, + buildStaticAnalysisSummary(stage2, parsed.totalDocs), + { + providedData: { clusterInfo: parsed.clusterInfo }, + modelId: input.modelId, + commandType: parsed.commandType, + collectionStats: parsed.collectionStats, + indexStats: parsed.indexStats, + }, + ); + if (controller.signal.aborted) return; + yield { type: 'status', phase: 'submitted', elapsedMs: elapsed(), charsReceived: 0 }; + const parser = new StreamingResponseParser(); + let charsReceived = 0; + let lastStatus = 0; + for await (const fragment of handle.fragments) { + if (controller.signal.aborted) return; + charsReceived += fragment.length; + for (const event of parser.feed(fragment)) { + if (controller.signal.aborted) return; + yield event; + } + if (elapsed() - lastStatus >= 250) { + lastStatus = elapsed(); + yield { type: 'status', phase: 'receiving', elapsedMs: elapsed(), charsReceived }; + } + } + if (controller.signal.aborted) return; + yield { type: 'status', phase: 'parsing', elapsedMs: elapsed(), charsReceived }; + const response = await handle.completion; + if (controller.signal.aborted) return; + for (const event of parser.finalize().events) { + if (controller.signal.aborted) return; + yield event; + } + yield { + type: 'complete', + durationMs: elapsed(), + modelId: response.modelId, + modelFamily: response.modelFamily, + modelDisplayName: response.modelDisplayName, + usage: response.usage, + modelInputs: response.modelInputs, + }; + } finally { + signal?.removeEventListener('abort', abort); + controller.abort(); + } + }), +}); diff --git a/src/webviews/documentdb/queryInsightsStandalone/samplePlans.ts b/src/webviews/documentdb/queryInsightsStandalone/samplePlans.ts new file mode 100644 index 000000000..6b8cfd642 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsStandalone/samplePlans.ts @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type SampleCommand = 'find' | 'aggregate' | 'count'; + +/** Small, realistic `explain("executionStats")` results for trying the explorer without a database. */ +const samples: Record = { + find: { + queryPlanner: { + namespace: 'shop.orders', + parsedQuery: { $and: [{ customerId: { $eq: 1042 } }, { status: { $eq: 'shipped' } }] }, + winningPlan: { + stage: 'SORT', + sortPattern: { orderDate: -1 }, + inputStage: { + stage: 'FETCH', + filter: { customerId: { $eq: 1042 } }, + inputStage: { stage: 'IXSCAN', keyPattern: { status: 1 }, indexName: 'status_1' }, + }, + }, + }, + executionStats: { + executionSuccess: true, + nReturned: 18, + executionTimeMillis: 37, + totalKeysExamined: 2412, + totalDocsExamined: 2412, + executionStages: { + stage: 'SORT', + nReturned: 18, + inputStage: { + stage: 'FETCH', + nReturned: 18, + docsExamined: 2412, + inputStage: { stage: 'IXSCAN', nReturned: 2412, keysExamined: 2412, indexName: 'status_1' }, + }, + }, + }, + command: { + find: 'orders', + filter: { status: 'shipped', customerId: 1042 }, + sort: { orderDate: -1 }, + $db: 'shop', + }, + }, + aggregate: { + stages: [ + { + $cursor: { + queryPlanner: { + namespace: 'shop.orders', + parsedQuery: { status: { $eq: 'shipped' } }, + winningPlan: { + stage: 'COLLSCAN', + filter: { status: { $eq: 'shipped' } }, + direction: 'forward', + }, + }, + executionStats: { + executionSuccess: true, + nReturned: 5120, + executionTimeMillis: 84, + totalKeysExamined: 0, + totalDocsExamined: 48210, + executionStages: { stage: 'COLLSCAN', nReturned: 5120, docsExamined: 48210 }, + }, + }, + nReturned: 5120, + executionTimeMillisEstimate: 61, + }, + { + $group: { _id: '$customerId', total: { $sum: '$amount' } }, + nReturned: 312, + executionTimeMillisEstimate: 80, + }, + { $sort: { sortKey: { total: -1 }, limit: 10 }, nReturned: 10, executionTimeMillisEstimate: 82 }, + ], + command: { + aggregate: 'orders', + pipeline: [ + { $match: { status: 'shipped' } }, + { $group: { _id: '$customerId', total: { $sum: '$amount' } } }, + { $sort: { total: -1 } }, + { $limit: 10 }, + ], + cursor: {}, + $db: 'shop', + }, + }, + count: { + queryPlanner: { + namespace: 'shop.orders', + parsedQuery: { status: { $eq: 'pending' } }, + winningPlan: { + stage: 'COUNT', + inputStage: { stage: 'COUNT_SCAN', keyPattern: { status: 1 }, indexName: 'status_1' }, + }, + }, + executionStats: { + executionSuccess: true, + nReturned: 0, + executionTimeMillis: 3, + totalKeysExamined: 1290, + totalDocsExamined: 0, + executionStages: { + stage: 'COUNT', + nReturned: 0, + nCounted: 1289, + inputStage: { stage: 'COUNT_SCAN', nReturned: 1289, keysExamined: 1290, indexName: 'status_1' }, + }, + }, + command: { count: 'orders', query: { status: 'pending' }, $db: 'shop' }, + }, +}; + +export function samplePlan(command: SampleCommand): string { + return JSON.stringify(samples[command], undefined, 2); +} + +export interface ShellSnippet { + readonly id: string; + /** Command name shown beside the snippet; not translated, it is shell syntax. */ + readonly label: string; + readonly code: string; +} + +/** What to run in the DocumentDB shell or mongosh. Shell syntax, so never localized. */ +export const explainSnippets: readonly ShellSnippet[] = [ + { + id: 'find', + label: 'find', + code: 'db.orders.find({ status: "shipped" }).sort({ orderDate: -1 }).explain("executionStats")', + }, + { + id: 'aggregate', + label: 'aggregate', + code: 'db.orders.explain("executionStats").aggregate([\n { $match: { status: "shipped" } },\n { $group: { _id: "$customerId", total: { $sum: "$amount" } } }\n])', + }, + { id: 'count', label: 'count', code: 'db.orders.explain("executionStats").count({ status: "pending" })' }, +]; + +export const contextSnippets: readonly ShellSnippet[] = [ + { id: 'stats', label: 'stats', code: 'db.orders.stats()' }, + { id: 'indexes', label: 'getIndexes', code: 'db.orders.getIndexes()' }, +]; diff --git a/src/webviews/documentdb/queryInsightsStandalone/types.ts b/src/webviews/documentdb/queryInsightsStandalone/types.ts new file mode 100644 index 000000000..f34fe6ed7 --- /dev/null +++ b/src/webviews/documentdb/queryInsightsStandalone/types.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface QueryInsightsPrefill { + explain?: string; + query?: string; + commandType?: 'find' | 'aggregate' | 'count'; + collectionStats?: string; + indexes?: string; + databaseName?: string; + collectionName?: string; + clusterType?: 'azure-documentdb' | 'documentdb' | 'other'; + /** Dev-only (L2 scenario): render a design preview instead of the wizard. */ + designPreview?: 'tab-model-selection'; +} + +export interface StandaloneInput { + explain: string; + query: string; + commandType: 'auto' | 'find' | 'aggregate' | 'count'; + collectionStats: string; + indexes: string; + databaseName: string; + collectionName: string; + clusterType: 'azure-documentdb' | 'documentdb' | 'other'; + modelId?: string; +}