diff --git a/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md b/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md index 6b0e366dcb10f..d6f684d58fefb 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md +++ b/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md @@ -309,25 +309,16 @@ allGroups = derived(permissionModeGroup, folderGroup) An `autorun` reads `allGroups` and writes to `state.groups`. This is the only place `state.groups` is written — the pipeline is the single source of truth for the UI. -### Lifetime Management (WeakRef + FinalizationRegistry) +### Lifetime Management (onDidDispose) -The `autorun`'s closure holds a `WeakRef` rather than a direct reference. This is required because the shared observables (`_workspaceFolders`, `_bypassPermissionsEnabled`) hold strong references to the autorun's observer. Without the `WeakRef`, each `state` object would be transitively reachable through the shared observable → autorun → closure → state chain, and would never be garbage collected. - -When VS Code discards a `ChatSessionInputState`, the `WeakRef` lets the GC collect it. The `FinalizationRegistry` (`_stateAutorunRegistry`) then fires and calls `store.dispose()`, which unsubscribes all autoruns for that state. - -``` -SharedObservable ──strong──► autorun observer - │ - WeakRef ← allows GC of state - │ - state.groups (written on change) -``` +Each pipeline's `store` is disposed via `state.onDidDispose`: ```typescript -_stateAutorunRegistry = new FinalizationRegistry(store => store.dispose()) -// registered as: _stateAutorunRegistry.register(state, pipeline.store) +pipeline.store.add(state.onDidDispose(() => pipeline.store.dispose())); ``` +When VS Code discards a `ChatSessionInputState`, the `onDidDispose` event fires and deterministically cleans up all autoruns for that state. The `onDidDispose` subscription is itself registered on the pipeline store, so it is cleaned up as part of disposal. + ### External Permission Mode Updates When Claude executes `EnterPlanMode` or `ExitPlanMode` tools, `claudeMessageDispatch.ts` calls `IClaudeSessionStateService.setPermissionModeForSession()`, which fires `onDidChangeSessionState`. The pipeline subscribes to this event via a second autorun: @@ -344,16 +335,13 @@ pipeline.store.add(autorun(reader => { })); ``` -This autorun is registered on `pipeline.store`, so it is disposed along with all other pipeline autoruns when the state is GC'd. +This autorun is registered on `pipeline.store`, so it is disposed along with all other pipeline autoruns when the state is disposed. ### Session-Started Signal -The `isSessionStarted` observable controls whether folder items carry `locked: true`. It is set in two places: - -- **Restoring an existing session** (new-state path): `pipeline.isSessionStarted.set(true, undefined)` in `_setupInputState` when `isExistingSession` is true. -- **First message sent** (new-untitled session): `ClaudeChatSessionContentProvider.createHandler()` calls `markSessionStarted(inputState)`, which looks up the pipeline from `_statePipelines` and sets `isSessionStarted` to `true`. This is how the folder gets locked after the user submits their first prompt. +The `isSessionStarted` observable controls whether folder items carry `locked: true`. It is set to `true` when `getChatSessionInputState` is called with a `sessionResource` — i.e., whenever VS Code provides a resource for the session. This covers both existing on-disk sessions and sessions that have been started (where a resource has been assigned). -`_statePipelines` is a `WeakMap` that enables these external mutations. The `WeakMap` does not prevent GC of state objects (WeakMap keys are held weakly), so it complements rather than interferes with the `FinalizationRegistry`. +For the `previousInputState` path, the lock state is recovered from the items themselves: `_computeSeedValues` checks for `locked: true` on folder items and restores `isSessionStarted` accordingly. ### Critical Invariant: Subscribe After Both Branches diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index e039be50461f9..4cd6a9c44e2f8 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -115,11 +115,6 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco const existingSession = await this.sessionService.getSession(sessionUri, token); const isNewSession = !existingSession; - // Lock the folder group when starting a new session (permission mode stays editable) - if (isNewSession) { - this._controller.markSessionStarted(chatSessionContext.inputState); - } - const modelId = parseClaudeModelId(request.model.id); const selectedPermissionId = chatSessionContext.inputState.groups.find(group => group.id === PERMISSION_MODE_OPTION_ID)?.selected?.id; if (!selectedPermissionId || !isPermissionMode(selectedPermissionId)) { @@ -207,13 +202,6 @@ export class ClaudeChatSessionItemController extends Disposable { /** Current workspace folders — controls folder group items and visibility. */ private readonly _workspaceFolders: IObservable; - /** Disposes per-state autoruns when the state object is garbage collected. */ - private readonly _stateAutorunRegistry = new FinalizationRegistry( - store => store.dispose() - ); - - /** Maps input state objects to their reactive pipelines for external updates. */ - private readonly _statePipelines = new WeakMap(); // #endregion @@ -357,9 +345,8 @@ export class ClaudeChatSessionItemController extends Disposable { * into derived group computations. An autorun reads the derived groups and pushes * the result to `state.groups`, which is the "UI". * - * The `state` is only held weakly by the autoruns so it can be garbage-collected - * while the shared observables still reference the pipeline's observers. When the - * state is collected, the finalization registry disposes the store and unsubscribes. + * The returned `DisposableStore` owns the autorun lifecycle and is disposed via + * `state.onDidDispose` in the caller. * * Returns the per-state observables so callers can drive external updates, plus a * `DisposableStore` that owns the autorun lifecycle. @@ -439,18 +426,9 @@ export class ClaudeChatSessionItemController extends Disposable { return groups; }); - // Hold `state` via a WeakRef so the autorun's closure does not retain it. - // Shared observables (`_workspaceFolders`, `_bypassPermissionsEnabled`) hold - // strong references to autoruns; without the WeakRef, `state` would transitively - // stay reachable forever and `_stateAutorunRegistry` could never fire. - const stateRef = new WeakRef(state); store.add(autorun(reader => { /** @description syncInputStateGroups */ - const groups = allGroups.read(reader); - const currentState = stateRef.deref(); - if (currentState) { - currentState.groups = groups; - } + state.groups = allGroups.read(reader); })); return { permissionMode, folderUri, folderItems, isSessionStarted, store }; @@ -471,16 +449,14 @@ export class ClaudeChatSessionItemController extends Disposable { : await this._optionBuilder.buildNewSessionGroups(); state = this._controller.createChatSessionInputState(initialGroups); pipeline = this._createInputStateReactivePipeline(state); - - if (isExistingSession) { - pipeline.isSessionStarted.set(true, undefined); - } } - // React to external permission mode changes for this session. - // Runs for both previousInputState and new-state paths so that - // EnterPlanMode / ExitPlanMode tool calls always update the input UI. if (sessionResource) { + pipeline.isSessionStarted.set(true, undefined); + + // React to external permission mode changes for this session. + // Runs for both previousInputState and new-state paths so that + // EnterPlanMode / ExitPlanMode tool calls always update the input UI. const sessionId = ClaudeSessionUri.getSessionId(sessionResource); const externalPermissionMode = observableFromEvent( this, @@ -494,8 +470,7 @@ export class ClaudeChatSessionItemController extends Disposable { })); } - this._statePipelines.set(state, pipeline); - this._stateAutorunRegistry.register(state, pipeline.store); + pipeline.store.add(state.onDidDispose(() => pipeline.store.dispose())); return state; }; } @@ -542,17 +517,6 @@ export class ClaudeChatSessionItemController extends Disposable { return { permissionMode, folderUri, folderItems, isSessionStarted }; } - /** - * Marks the input state as "started", which locks the folder group. - * Called by the content provider when a new session begins. - */ - markSessionStarted(inputState: vscode.ChatSessionInputState): void { - const pipeline = this._statePipelines.get(inputState); - if (pipeline) { - pipeline.isSessionStarted.set(true, undefined); - } - } - private async _buildExistingSessionGroups(sessionResource: vscode.Uri): Promise { const sessionId = ClaudeSessionUri.getSessionId(sessionResource); const permissionMode = this._sessionStateService.getPermissionModeForSession(sessionId); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts index 1330ca877952c..84e648b0121bc 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts @@ -1128,41 +1128,26 @@ describe('ChatSessionContentProvider', () => { expect(getGroup(restoredState, 'permissionMode')!.selected?.id).toBe('acceptEdits'); }); - it('markSessionStarted locks the folder group mid-session', async () => { + it('sessionResource locks the folder group for existing sessions', async () => { const mocks = createDefaultMocks(); createProviderWithServices(store, [folderA, folderB], mocks); - const state = await getInputState(); - let folderGroup = getGroup(state, 'folder')!; + // New session (no sessionResource) — folder is unlocked + const newState = await getInputState(); + let folderGroup = getGroup(newState, 'folder')!; expect(folderGroup.items.every(i => !i.locked)).toBe(true); expect(folderGroup.selected?.locked).toBeUndefined(); - // Simulate a new session starting by invoking the handler (which calls markSessionStarted) - // The handler is owned by the content provider — we go through it via createHandler. - // Easier: reach through via the exported accessor pattern — call markSessionStarted through the controller. - // The content provider does not export the controller, but the handler path covers it. - vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue(undefined); - seedSessionItem('new-session'); - - const { provider: handlerProvider } = createProviderWithServices(store, [folderA, folderB], mocks); - const handler = handlerProvider.createHandler(); - // The state we want to observe must be the one passed into the handler - const newState = await getInputState(); - const context: vscode.ChatContext = { - history: [], - yieldRequested: false, - chatSessionContext: { - isUntitled: false, - chatSessionItem: { - resource: ClaudeSessionUri.forSessionId('new-session'), - label: 'New', - }, - inputState: newState, - }, - } as vscode.ChatContext; - await handler(createTestRequest('hello'), context, new MockChatResponseStream(), CancellationToken.None); + // Existing session (sessionResource provided) — folder is locked + vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue({ + id: 'started-session', + messages: [{ type: 'user', message: { role: 'user', content: 'Hello' } }], + subagents: [], + } as any); + const sessionUri = createClaudeSessionUri('started-session'); + const startedState = await getInputState(sessionUri); - folderGroup = getGroup(newState, 'folder')!; + folderGroup = getGroup(startedState, 'folder')!; expect(folderGroup.items.every(i => i.locked === true)).toBe(true); expect(folderGroup.selected?.locked).toBe(true); }); diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts index 6f8be91bd4fa6..22128dd5dab8f 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts @@ -314,7 +314,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { assert.strictEqual(output.trim(), marker); }); - test('network requests to allowlisted domains succeed in sandbox', async function () { + test.skip('network requests to allowlisted domains succeed in sandbox', async function () { this.timeout(60000); const configuration = vscode.workspace.getConfiguration(); @@ -332,7 +332,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { } }); - test('requestUnsandboxedExecution preserves sandbox $TMPDIR', async function () { + test.skip('requestUnsandboxedExecution preserves sandbox $TMPDIR', async function () { this.timeout(60000); const marker = `SANDBOX_UNSANDBOX_${Date.now()}`; @@ -370,7 +370,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { assert.ok(trimmed.endsWith(marker), `Unexpected output: ${JSON.stringify(trimmed)}`); }); - test('can read files outside the workspace', async function () { + test.skip('can read files outside the workspace', async function () { this.timeout(60000); const output = await invokeRunInTerminal('head -1 /etc/shells'); @@ -397,7 +397,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { assert.strictEqual(output.trim(), marker); }); - test('$TMPDIR is writable inside the sandbox', async function () { + test.skip('$TMPDIR is writable inside the sandbox', async function () { this.timeout(60000); const marker = `SANDBOX_TMPDIR_${Date.now()}`; @@ -411,7 +411,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { assert.strictEqual(lastLine, marker, `Unexpected output: ${JSON.stringify(trimmed)}`); }); - test('non-allowlisted domains trigger unsandboxed confirmation flow', async function () { + test.skip('non-allowlisted domains trigger unsandboxed confirmation flow', async function () { this.timeout(60000); const marker = `SANDBOX_DOMAIN_${Date.now()}`; diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 015259b1a24ff..d50e7c2e5f236 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -178,7 +178,7 @@ export interface IAgentSessionConfigCompletionsParams extends IAgentResolveSessi /** Serializable attachment passed alongside a message to the agent host. */ export interface IAgentAttachment { readonly type: AttachmentType; - readonly path: string; + readonly uri: URI; readonly displayName?: string; /** For selections: the selected text. */ readonly text?: string; diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 4f86f0d986737..6696950658362 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -3948d65 +8611f76 diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index 7e773e7e78d4c..ea44938f73576 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -9,7 +9,7 @@ // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. -import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionTurnStartedAction, type SessionDeltaAction, type SessionResponsePartAction, type SessionToolCallStartAction, type SessionToolCallDeltaAction, type SessionToolCallReadyAction, type SessionToolCallConfirmedAction, type SessionToolCallCompleteAction, type SessionToolCallResultConfirmedAction, type SessionToolCallContentChangedAction, type SessionTurnCompleteAction, type SessionTurnCancelledAction, type SessionErrorAction, type SessionTitleChangedAction, type SessionUsageAction, type SessionReasoningAction, type SessionModelChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientChangedAction, type SessionActiveClientToolsChangedAction, type SessionPendingMessageSetAction, type SessionPendingMessageRemovedAction, type SessionQueuedMessagesReorderedAction, type SessionInputRequestedAction, type SessionInputAnswerChangedAction, type SessionInputCompletedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionTruncatedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionDiffsChangedAction, type SessionConfigChangedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction } from './actions.js'; +import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionTurnStartedAction, type SessionDeltaAction, type SessionResponsePartAction, type SessionToolCallStartAction, type SessionToolCallDeltaAction, type SessionToolCallReadyAction, type SessionToolCallConfirmedAction, type SessionToolCallCompleteAction, type SessionToolCallResultConfirmedAction, type SessionToolCallContentChangedAction, type SessionTurnCompleteAction, type SessionTurnCancelledAction, type SessionErrorAction, type SessionTitleChangedAction, type SessionUsageAction, type SessionReasoningAction, type SessionModelChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientChangedAction, type SessionActiveClientToolsChangedAction, type SessionPendingMessageSetAction, type SessionPendingMessageRemovedAction, type SessionQueuedMessagesReorderedAction, type SessionInputRequestedAction, type SessionInputAnswerChangedAction, type SessionInputCompletedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionTruncatedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionDiffsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction } from './actions.js'; // ─── Root vs Session vs Terminal Action Unions ─────────────────────────────── @@ -57,8 +57,10 @@ export type SessionAction = | SessionTruncatedAction | SessionIsReadChangedAction | SessionIsArchivedChangedAction + | SessionActivityChangedAction | SessionDiffsChangedAction | SessionConfigChangedAction + | SessionMetaChangedAction ; /** Union of session actions that clients may dispatch. */ @@ -101,7 +103,9 @@ export type ServerSessionAction = | SessionServerToolsChangedAction | SessionInputRequestedAction | SessionCustomizationsChangedAction + | SessionActivityChangedAction | SessionDiffsChangedAction + | SessionMetaChangedAction ; /** Union of all terminal-scoped actions. */ @@ -182,8 +186,10 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.SessionTruncated]: true, [ActionType.SessionIsReadChanged]: true, [ActionType.SessionIsArchivedChanged]: true, + [ActionType.SessionActivityChanged]: false, [ActionType.SessionDiffsChanged]: false, [ActionType.SessionConfigChanged]: true, + [ActionType.SessionMetaChanged]: false, [ActionType.TerminalData]: false, [ActionType.TerminalInput]: true, [ActionType.TerminalResized]: true, diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts index 9865fc05bf52c..1d3510a353cea 100644 --- a/src/vs/platform/agentHost/common/state/protocol/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -52,8 +52,10 @@ export const enum ActionType { SessionTruncated = 'session/truncated', SessionIsReadChanged = 'session/isReadChanged', SessionIsArchivedChanged = 'session/isArchivedChanged', + SessionActivityChanged = 'session/activityChanged', SessionDiffsChanged = 'session/diffsChanged', SessionConfigChanged = 'session/configChanged', + SessionMetaChanged = 'session/metaChanged', RootTerminalsChanged = 'root/terminalsChanged', RootConfigChanged = 'root/configChanged', TerminalData = 'terminal/data', @@ -598,6 +600,23 @@ export interface SessionIsArchivedChangedAction { isArchived: boolean; } +/** + * The activity description of the session changed. + * + * Dispatched by the server to indicate what the session is currently doing + * (e.g. running a tool, thinking). Clear activity by setting it to `undefined`. + * + * @category Session Actions + * @version 1 + */ +export interface SessionActivityChangedAction { + type: ActionType.SessionActivityChanged; + /** Session URI */ + session: URI; + /** Human-readable description of current activity, or `undefined` to clear */ + activity: string | undefined; +} + /** * The file diffs for the session changed. * @@ -732,6 +751,22 @@ export interface SessionConfigChangedAction { replace?: boolean; } +/** + * The session's `_meta` side-channel changed. Replaces `state._meta` + * entirely (full-replacement semantics). Producers SHOULD merge any + * keys they wish to preserve into the new value before dispatching. + * + * @category Session Actions + * @version 1 + */ +export interface SessionMetaChangedAction { + type: ActionType.SessionMetaChanged; + /** Session URI */ + session: URI; + /** New `_meta` payload, or `undefined` to clear it */ + _meta: Record | undefined; +} + // ─── Truncation ────────────────────────────────────────────────────────────── /** @@ -1143,8 +1178,10 @@ export type StateAction = | SessionTruncatedAction | SessionIsReadChangedAction | SessionIsArchivedChangedAction + | SessionActivityChangedAction | SessionDiffsChangedAction | SessionConfigChangedAction + | SessionMetaChangedAction | TerminalDataAction | TerminalInputAction | TerminalResizedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/reducers.ts b/src/vs/platform/agentHost/common/state/protocol/reducers.ts index 08bd0e7094f9d..1282ba964ed6f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/reducers.ts +++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts @@ -578,6 +578,12 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: summary: { ...state.summary, status: withStatusFlag(state.summary.status, SessionStatus.IsArchived, action.isArchived) }, }; + case ActionType.SessionActivityChanged: + return { + ...state, + summary: { ...state.summary, activity: action.activity }, + }; + case ActionType.SessionDiffsChanged: return { ...state, @@ -600,6 +606,9 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: }, }; + case ActionType.SessionMetaChanged: + return { ...state, _meta: action._meta }; + case ActionType.SessionServerToolsChanged: return { ...state, serverTools: action.tools }; diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index 3acd55a9655df..fe256b98ad7c9 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -331,6 +331,14 @@ export interface SessionState { * {@link SessionActiveClient.customizations | activeClient.customizations}. */ customizations?: SessionCustomization[]; + /** + * Additional provider-specific metadata for this session. + * + * Clients MAY look for well-known keys here to provide enhanced UI. + * For example, a `git` key may provide extra git metadata about the session's + * workingDirectory. + */ + _meta?: Record; } /** @@ -376,6 +384,8 @@ export interface SessionSummary { title: string; /** Current session status */ status: SessionStatus; + /** Human-readable description of what the session is currently doing */ + activity?: string; /** Creation timestamp */ createdAt: number; /** Last modification timestamp */ @@ -585,11 +595,20 @@ export interface SessionInputTextQuestion extends SessionInputQuestionBase { /** Numeric question within a session input request. */ export interface SessionInputNumberQuestion extends SessionInputQuestionBase { kind: SessionInputQuestionKind.Number | SessionInputQuestionKind.Integer; - /** Minimum value */ + /** + * Minimum value + * @format float + */ min?: number; - /** Maximum value */ + /** + * Maximum value + * @format float + */ max?: number; - /** Default numeric value */ + /** + * Default numeric value + * @format float + */ defaultValue?: number; } @@ -680,6 +699,7 @@ export interface SessionInputTextAnswerValue { export interface SessionInputNumberAnswerValue { kind: SessionInputAnswerValueKind.Number; + /** @format float */ value: number; } @@ -825,8 +845,8 @@ export interface UserMessage { export interface MessageAttachment { /** Attachment type */ type: AttachmentType; - /** File/directory path */ - path: string; + /** File/directory URI */ + uri: URI; /** Display name */ displayName?: string; } @@ -1454,6 +1474,11 @@ export interface SessionCustomization { customization: CustomizationRef; /** Whether this customization is currently enabled */ enabled: boolean; + /** + * The `clientId` of the client that contributed this customization. + * Absent for server-provided customizations. + */ + clientId?: string; /** Server-reported loading status */ status?: CustomizationStatus; /** diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 3487fa7532493..54eba9495899e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -59,8 +59,10 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: number [ActionType.SessionTruncated]: 1, [ActionType.SessionIsReadChanged]: 1, [ActionType.SessionIsArchivedChanged]: 1, + [ActionType.SessionActivityChanged]: 1, [ActionType.SessionDiffsChanged]: 1, [ActionType.SessionConfigChanged]: 1, + [ActionType.SessionMetaChanged]: 1, [ActionType.RootTerminalsChanged]: 1, [ActionType.RootConfigChanged]: 1, [ActionType.TerminalData]: 1, diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 91b8f804f4276..241048fffc449 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -610,7 +610,7 @@ export class AgentSideEffects extends Disposable { } const attachments = action.userMessage.attachments?.map((a): IAgentAttachment => ({ type: a.type, - path: a.path, + uri: URI.parse(a.uri), displayName: a.displayName, })); agent.sendMessage(URI.parse(action.session), action.userMessage.text, attachments, action.turnId).catch(err => { @@ -864,7 +864,7 @@ export class AgentSideEffects extends Disposable { } const attachments = msg.userMessage.attachments?.map((a): IAgentAttachment => ({ type: a.type, - path: a.path, + uri: URI.parse(a.uri), displayName: a.displayName, })); agent.sendMessage(URI.parse(session), msg.userMessage.text, attachments, turnId).catch(err => { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 08684c105778e..5ff06ce1f3c39 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -300,10 +300,11 @@ export class CopilotAgentSession extends Disposable { this._logService.info(`[Copilot:${this.sessionId}] sendMessage called: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}" (${attachments?.length ?? 0} attachments)`); const sdkAttachments = attachments?.map(a => { + const path = a.uri.scheme === 'file' ? a.uri.fsPath : a.uri.toString(); if (a.type === 'selection') { - return { type: 'selection' as const, filePath: a.path, displayName: a.displayName ?? a.path, text: a.text, selection: a.selection }; + return { type: 'selection' as const, filePath: path, displayName: a.displayName ?? path, text: a.text, selection: a.selection }; } - return { type: a.type, path: a.path, displayName: a.displayName }; + return { type: a.type, path, displayName: a.displayName }; }); if (sdkAttachments?.length) { this._logService.trace(`[Copilot:${this.sessionId}] Attachments: ${JSON.stringify(sdkAttachments.map(a => ({ type: a.type, path: a.type === 'selection' ? a.filePath : a.path })))}`); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index b34ea5d47bacf..72bd6e6c971e5 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -20,7 +20,7 @@ import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType, ActionEnvelope, SessionAction } from '../../common/state/sessionActions.js'; -import { buildSubagentSessionUri, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType } from '../../common/state/sessionState.js'; +import { AttachmentType, buildSubagentSessionUri, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallStatus, ToolResultContentType } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentService } from '../../node/agentService.js'; @@ -122,7 +122,26 @@ suite('AgentSideEffects', () => { // sendMessage is async but fire-and-forget; wait a tick await new Promise(r => setTimeout(r, 10)); - assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello world' }]); + assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello world', attachments: undefined }]); + }); + + test('parses protocol attachment URI strings before passing them to the agent', () => { + setupSession(); + const fileUri = URI.file('/workspace/test.ts'); + const action: SessionAction = { + type: ActionType.SessionTurnStarted, + session: sessionUri.toString(), + turnId: 'turn-1', + userMessage: { text: 'hello world', attachments: [{ type: AttachmentType.File, uri: fileUri.toString(), displayName: 'test.ts' }] }, + }; + + sideEffects.handleAction(action); + + assert.deepStrictEqual(agent.sendMessageCalls, [{ + session: URI.parse(sessionUri.toString()), + prompt: 'hello world', + attachments: [{ type: AttachmentType.File, uri: URI.parse(fileUri.toString()), displayName: 'test.ts' }], + }]); }); test('dispatches session/error when no agent is found', async () => { @@ -462,6 +481,27 @@ suite('AgentSideEffects', () => { assert.strictEqual(agent.sendMessageCalls[0].prompt, 'queued message'); }); + test('parses queued protocol attachment URI strings before passing them to the agent', () => { + setupSession(); + const fileUri = URI.file('/workspace/queued.ts'); + const action: SessionAction = { + type: ActionType.SessionPendingMessageSet as const, + session: sessionUri.toString(), + kind: PendingMessageKind.Queued, + id: 'q-uri', + userMessage: { text: 'queued message', attachments: [{ type: AttachmentType.File, uri: fileUri.toString(), displayName: 'queued.ts' }] }, + }; + + stateManager.dispatchClientAction(action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(action); + + assert.deepStrictEqual(agent.sendMessageCalls, [{ + session: URI.parse(sessionUri.toString()), + prompt: 'queued message', + attachments: [{ type: AttachmentType.File, uri: URI.parse(fileUri.toString()), displayName: 'queued.ts' }], + }]); + }); + test('syncs on SessionPendingMessageRemoved', () => { setupSession(); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index c8aacfc9f9402..a59fafd93268f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -19,7 +19,7 @@ import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentSession, IAgentProgressEvent, IAgentUserInputRequestEvent } from '../../common/agentService.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; -import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType } from '../../common/state/sessionState.js'; +import { AttachmentType, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType } from '../../common/state/sessionState.js'; import { CopilotAgentSession, IActiveClientSnapshot, SessionWrapperFactory } from '../../node/copilot/copilotAgentSession.js'; import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; import { createSessionDataService, createZeroDiffComputeService } from '../common/sessionTestHelpers.js'; @@ -33,6 +33,7 @@ import { createSessionDataService, createZeroDiffComputeService } from '../commo */ class MockCopilotSession { readonly sessionId = 'test-session-1'; + readonly sendRequests: unknown[] = []; private readonly _handlers = new Map void>>(); @@ -58,7 +59,7 @@ class MockCopilotSession { } // Stubs for methods the wrapper / session class calls - async send() { return ''; } + async send(request: unknown) { this.sendRequests.push(request); return ''; } async abort() { } async setModel() { } async getMessages() { return []; } @@ -191,6 +192,25 @@ suite('CopilotAgentSession', () => { teardown(() => disposables.clear()); ensureNoDisposablesAreLeakedInTestSuite(); + test('maps internal attachment URIs to Copilot SDK path fields', async () => { + const { session, mockSession } = await createAgentSession(disposables); + const fileUri = URI.file('/workspace/file.ts'); + const selectionUri = URI.file('/workspace/selection.ts'); + + await session.send('hello', [ + { type: AttachmentType.File, uri: fileUri, displayName: 'file.ts' }, + { type: AttachmentType.Selection, uri: selectionUri, displayName: 'selection.ts' }, + ]); + + assert.deepStrictEqual(mockSession.sendRequests, [{ + prompt: 'hello', + attachments: [ + { type: 'file', path: fileUri.fsPath, displayName: 'file.ts' }, + { type: 'selection', filePath: selectionUri.fsPath, displayName: 'selection.ts', text: undefined, selection: undefined }, + ], + }]); + }); + // ---- permission handling ---- suite('permission handling', () => { diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index e89c5ecf0d6d1..dab37c38259d9 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -35,7 +35,7 @@ export class MockAgent implements IAgent { private _nextId = 1; - readonly sendMessageCalls: { session: URI; prompt: string }[] = []; + readonly sendMessageCalls: { session: URI; prompt: string; attachments?: readonly IAgentAttachment[] }[] = []; readonly setPendingMessagesCalls: { session: URI; steeringMessage: PendingMessage | undefined; queuedMessages: readonly PendingMessage[] }[] = []; readonly disposeSessionCalls: URI[] = []; readonly abortSessionCalls: URI[] = []; @@ -92,8 +92,8 @@ export class MockAgent implements IAgent { return { items: [] }; } - async sendMessage(session: URI, prompt: string): Promise { - this.sendMessageCalls.push({ session, prompt }); + async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise { + this.sendMessageCalls.push({ session, prompt, attachments }); } setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[]): void { diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index f691d6641ea1c..b8f2d30fc7769 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -55,7 +55,6 @@ src/vs/sessions/contrib/chat/browser/ └── promptsService.ts # AgenticPromptsService (CLI user roots) src/vs/sessions/contrib/sessions/browser/ ├── aiCustomizationShortcutsWidget.ts # Shortcuts widget -├── customizationCounts.ts # Source count utilities (type-aware) └── customizationsToolbar.contribution.ts # Sidebar customization links ``` @@ -220,12 +219,18 @@ Skills that are directly invoked by UI elements (toolbar buttons, menu items) ar ### Count Consistency -`customizationCounts.ts` uses the **same data sources** as the list widget. When a harness with an `itemProvider` is active (determined by `getActiveItemProvider()`), counts come from that provider's `provideChatSessionCustomizations()`. Otherwise, both counts and the list go through the `PromptsServiceCustomizationItemProvider` fallback, ensuring counts match what the list displays. +Counts shown in the sidebar (per-link badges and the header total in `AICustomizationShortcutsWidget`) are driven by the same `IAICustomizationItemsModel` singleton (`workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts`) that feeds the customizations editor's list widget. The model owns the per-active-harness `ProviderCustomizationItemSource` cache and exposes per-section `IObservable`; sidebar consumers `read` `.length` from those observables. There is exactly one discovery path, so editor and sidebar counts cannot diverge. McpServers and Plugins use their own service observables (`IMcpService.servers`, `IAgentPluginService.plugins`) directly. ### Item Badges `IAICustomizationListItem.badge` is an optional string that renders as a small inline tag next to the item name (same visual style as the MCP "Bridged" badge). For context instructions, this badge shows the raw `applyTo` pattern (e.g. a glob like `**/*.ts`), while the tooltip (`badgeTooltip`) explains the behavior. For skills with UI integrations, the badge reads "UI Integration" with a tooltip describing which UI surface invokes the skill. The badge text is also included in search filtering. +### Embedded Detail Editors + +The management editor opens inline detail panes for prompt files, MCP servers, and plugins. Prompt-file details use the standard text editor pane. MCP and plugin details render dedicated compact widgets — `EmbeddedMcpServerDetail` and `EmbeddedAgentPluginDetail` — purpose-built for the narrow split-pane host. They show the icon, name, scope/source, and description. Do **not** embed the full extension-editor panes inside the split-pane host: they assume a wide page-level layout and don't shrink cleanly. + +The MCP detail fixture in `src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts` must open a real server row (not a group header) and use a local server with concrete config so the compact widget's scope/description rendering is covered by screenshots. + ### Debug Panel Toggle via Command Palette: "Toggle Customizations Debug Panel". Shows a diagnostic view of the item pipeline: diff --git a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts index cf696435464c1..ee724ffedbf93 100644 --- a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts @@ -8,23 +8,19 @@ import './media/customizationsToolbar.css'; import * as DOM from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { autorun, derived } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; -import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationItemsModel } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; +import { CUSTOMIZATION_ITEMS } from './customizationsToolbar.contribution.js'; import { Menus } from '../../../browser/menus.js'; -import { getCustomizationTotalCount, getActiveItemProvider } from './customizationCounts.js'; import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; -import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; -import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; const $ = DOM.$; @@ -43,13 +39,9 @@ export class AICustomizationShortcutsWidget extends Disposable { options: IAICustomizationShortcutsWidgetOptions | undefined, @IInstantiationService private readonly instantiationService: IInstantiationService, @IStorageService private readonly storageService: IStorageService, - @IPromptsService private readonly promptsService: IPromptsService, @IMcpService private readonly mcpService: IMcpService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, - @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, - @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @IAICustomizationItemsModel private readonly itemsModel: IAICustomizationItemsModel, ) { super(); @@ -104,40 +96,29 @@ export class AICustomizationShortcutsWidget extends Disposable { options?.onDidChangeLayout?.(); })); - let updateCountRequestId = 0; - - const updateHeaderTotalCount = async () => { - const requestId = ++updateCountRequestId; - const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService, this.workspaceContextService, this.agentPluginService, getActiveItemProvider(this.sessionsManagementService, this.harnessService)); - if (requestId !== updateCountRequestId) { - return; + // Header total = sum of the same counts shown by each visible sidebar + // link (CUSTOMIZATION_ITEMS). This guarantees the header value equals + // the sum of the per-link badges by construction — and excludes + // sections like Prompts that the editor exposes but the sidebar does + // not surface. + const totalCount = derived(reader => { + let total = 0; + for (const config of CUSTOMIZATION_ITEMS) { + if (config.modelSection) { + total += this.itemsModel.getCount(config.modelSection).read(reader); + } else if (config.isMcp) { + total += this.mcpService.servers.read(reader).length; + } else if (config.isPlugins) { + total += this.agentPluginService.plugins.read(reader).length; + } } - - headerTotalCount.classList.toggle('hidden', totalCount === 0); - headerTotalCount.textContent = `${totalCount}`; - }; - - this._register(this.promptsService.onDidChangeCustomAgents(() => updateHeaderTotalCount())); - this._register(this.promptsService.onDidChangeSlashCommands(() => updateHeaderTotalCount())); - this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => updateHeaderTotalCount())); + return total; + }); this._register(autorun(reader => { - this.mcpService.servers.read(reader); - updateHeaderTotalCount(); - })); - this._register(autorun(reader => { - this.workspaceService.activeProjectRoot.read(reader); - updateHeaderTotalCount(); - })); - this._register(autorun(reader => { - this.sessionsManagementService.activeSession.read(reader); - this.harnessService.availableHarnesses.read(reader); - const provider = getActiveItemProvider(this.sessionsManagementService, this.harnessService); - if (provider) { - reader.store.add(provider.onDidChange(() => updateHeaderTotalCount())); - } - updateHeaderTotalCount(); + const value = totalCount.read(reader); + headerTotalCount.classList.toggle('hidden', value === 0); + headerTotalCount.textContent = `${value}`; })); - updateHeaderTotalCount(); // Toggle collapse on header click const transitionListener = this._register(new MutableDisposable()); diff --git a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts deleted file mode 100644 index 90c1b0719c136..0000000000000 --- a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts +++ /dev/null @@ -1,178 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { isEqualOrParent } from '../../../../base/common/resources.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; -import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; -import { BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js'; -import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; -import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; -import { parseHooksFromFile } from '../../../../workbench/contrib/chat/common/promptSyntax/hookCompatibility.js'; -import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; -import { ICustomizationHarnessService, ICustomizationItemProvider } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; -import { parse as parseJSONC } from '../../../../base/common/jsonc.js'; -import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; - -export interface ISourceCounts { - readonly workspace: number; - readonly user: number; - readonly extension: number; - readonly builtin: number; -} - -const storageToCountKey: Partial> = { - [PromptsStorage.local]: 'workspace', - [PromptsStorage.user]: 'user', - [PromptsStorage.extension]: 'extension', - [BUILTIN_STORAGE]: 'builtin', -}; - -export function getSourceCountsTotal(counts: ISourceCounts, filter: IStorageSourceFilter): number { - let total = 0; - for (const storage of filter.sources) { - const key = storageToCountKey[storage]; - if (key) { - total += counts[key]; - } - } - return total; -} - -/** - * Gets source counts for a prompt type, using the SAME data sources as - * loadItems() in the list widget to avoid count mismatches. - */ -export async function getSourceCounts( - promptsService: IPromptsService, - promptType: PromptsType, - filter: IStorageSourceFilter, - workspaceContextService: IWorkspaceContextService, - workspaceService: IAICustomizationWorkspaceService, - fileService?: IFileService, -): Promise { - const items: { storage: PromptsStorage; uri: URI }[] = []; - - if (promptType === PromptsType.agent) { - // Must match loadItems: uses getCustomAgents() - const agents = await promptsService.getCustomAgents(CancellationToken.None); - for (const a of agents) { - items.push({ storage: a.source.storage, uri: a.uri }); - } - } else if (promptType === PromptsType.skill) { - // Must match loadItems: uses findAgentSkills() - const skills = await promptsService.findAgentSkills(CancellationToken.None); - for (const s of skills ?? []) { - items.push({ storage: s.storage, uri: s.uri }); - } - } else if (promptType === PromptsType.prompt) { - // Must match loadItems: uses getPromptSlashCommands() filtering out skills - const commands = await promptsService.getPromptSlashCommands(CancellationToken.None); - for (const c of commands) { - if (c.type === PromptsType.skill) { - continue; - } - items.push({ storage: c.storage, uri: c.uri }); - } - } else if (promptType === PromptsType.instructions) { - // Must match loadItems: uses listPromptFiles + listAgentInstructions - const promptFiles = await promptsService.listPromptFiles(promptType, CancellationToken.None); - for (const f of promptFiles) { - items.push({ storage: f.storage, uri: f.uri }); - } - const agentInstructions = await promptsService.listAgentInstructions(CancellationToken.None, undefined); - const workspaceFolderUris = workspaceContextService.getWorkspace().folders.map(f => f.uri); - const activeRoot = workspaceService.getActiveProjectRoot(); - if (activeRoot) { - workspaceFolderUris.push(activeRoot); - } - for (const file of agentInstructions) { - const isWorkspaceFile = workspaceFolderUris.some(root => isEqualOrParent(file.uri, root)); - items.push({ - storage: isWorkspaceFile ? PromptsStorage.local : PromptsStorage.user, - uri: file.uri, - }); - } - } else if (promptType === PromptsType.hook && fileService) { - // Must match loadItems: parse individual hooks from each file - const hookFiles = await promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); - const activeRoot = workspaceService.getActiveProjectRoot(); - for (const hookFile of hookFiles) { - try { - const content = await fileService.readFile(hookFile.uri); - const json = parseJSONC(content.value.toString()); - const { hooks } = parseHooksFromFile(hookFile.uri, json, activeRoot, ''); - if (hooks.size > 0) { - for (const [, entry] of hooks) { - for (let i = 0; i < entry.hooks.length; i++) { - items.push({ storage: hookFile.storage, uri: hookFile.uri }); - } - } - } else { - items.push({ storage: hookFile.storage, uri: hookFile.uri }); - } - } catch { - items.push({ storage: hookFile.storage, uri: hookFile.uri }); - } - } - } else { - // hooks and anything else: uses listPromptFiles - const files = await promptsService.listPromptFiles(promptType, CancellationToken.None); - for (const f of files) { - items.push({ storage: f.storage, uri: f.uri }); - } - } - - // Apply the same storage source filter as the list widget - const filtered = applyStorageSourceFilter(items, filter); - return { - workspace: filtered.filter(i => i.storage === PromptsStorage.local).length, - user: filtered.filter(i => i.storage === PromptsStorage.user).length, - extension: filtered.filter(i => i.storage === PromptsStorage.extension).length, - builtin: filtered.filter(i => i.storage === BUILTIN_STORAGE).length, - }; -} - -const PROMPT_TYPES: PromptsType[] = [PromptsType.agent, PromptsType.skill, PromptsType.instructions, PromptsType.hook]; -const PROMPT_TYPE_SET = new Set(PROMPT_TYPES); - -export async function getCustomizationTotalCount( - promptsService: IPromptsService, - mcpService: IMcpService, - workspaceService: IAICustomizationWorkspaceService, - workspaceContextService: IWorkspaceContextService, - agentPluginService?: IAgentPluginService, - itemProvider?: ICustomizationItemProvider, -): Promise { - let promptTotal: number; - if (itemProvider) { - const allItems = await itemProvider.provideChatSessionCustomizations(CancellationToken.None); - promptTotal = allItems?.filter(item => PROMPT_TYPE_SET.has(item.type)).length ?? 0; - } else { - const results = await Promise.all(PROMPT_TYPES.map(type => { - const filter = workspaceService.getStorageSourceFilter(type); - return getSourceCounts(promptsService, type, filter, workspaceContextService, workspaceService) - .then(counts => getSourceCountsTotal(counts, filter)); - })); - promptTotal = results.reduce((sum, n) => sum + n, 0); - } - - const pluginCount = agentPluginService?.plugins.get().length ?? 0; - return promptTotal + mcpService.servers.get().length + pluginCount; -} - -export function getActiveItemProvider( - sessionsManagementService: ISessionsManagementService, - harnessService: ICustomizationHarnessService, -): ICustomizationItemProvider | undefined { - const sessionType = sessionsManagementService.activeSession.get()?.sessionType; - if (sessionType) { - return harnessService.findHarnessById(sessionType)?.itemProvider; - } - return undefined; -} diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index c4062dc948f2c..ac9d806c0a318 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -5,7 +5,6 @@ import '../../../browser/media/sidebarActionButton.css'; import './media/customizationsToolbar.css'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../nls.js'; @@ -15,9 +14,7 @@ import { IInstantiationService, ServicesAccessor } from '../../../../platform/in import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; -import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; -import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { IAICustomizationItemsModel, ItemsModelSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { Menus } from '../../../browser/menus.js'; import { agentIcon, instructionsIcon, mcpServerIcon, pluginIcon, skillIcon, hookIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; @@ -25,13 +22,10 @@ import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../base/bro import { IAction } from '../../../../base/common/actions.js'; import { $, append } from '../../../../base/browser/dom.js'; import { autorun } from '../../../../base/common/observable.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { getSourceCounts, getSourceCountsTotal, getActiveItemProvider } from './customizationCounts.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; -import { AICustomizationManagementSection, IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -41,7 +35,8 @@ export interface ICustomizationItemConfig { readonly label: string; readonly icon: ThemeIcon; readonly section: typeof AICustomizationManagementSection[keyof typeof AICustomizationManagementSection]; - readonly promptType?: PromptsType; + /** If set, count comes from `IAICustomizationItemsModel.getCount(modelSection)`. */ + readonly modelSection?: ItemsModelSection; readonly isMcp?: boolean; readonly isPlugins?: boolean; } @@ -52,28 +47,28 @@ export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ label: localize('agents', "Agents"), icon: agentIcon, section: AICustomizationManagementSection.Agents, - promptType: PromptsType.agent, + modelSection: AICustomizationManagementSection.Agents, }, { id: 'sessions.customization.skills', label: localize('skills', "Skills"), icon: skillIcon, section: AICustomizationManagementSection.Skills, - promptType: PromptsType.skill, + modelSection: AICustomizationManagementSection.Skills, }, { id: 'sessions.customization.instructions', label: localize('instructions', "Instructions"), icon: instructionsIcon, section: AICustomizationManagementSection.Instructions, - promptType: PromptsType.instructions, + modelSection: AICustomizationManagementSection.Instructions, }, { id: 'sessions.customization.hooks', label: localize('hooks', "Hooks"), icon: hookIcon, section: AICustomizationManagementSection.Hooks, - promptType: PromptsType.hook, + modelSection: AICustomizationManagementSection.Hooks, }, { id: 'sessions.customization.mcpServers', @@ -93,7 +88,9 @@ export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ /** * Custom ActionViewItem for each customization link in the toolbar. - * Renders icon + label + source count badges, matching the sidebar footer style. + * Renders icon + label + a single count badge driven by the same + * `IAICustomizationItemsModel` observables that feed the customizations + * editor — so the badge always matches the editor's count exactly. */ export class CustomizationLinkViewItem extends ActionViewItem { @@ -105,15 +102,9 @@ export class CustomizationLinkViewItem extends ActionViewItem { action: IAction, options: IBaseActionViewItemOptions, private readonly _config: ICustomizationItemConfig, - @IPromptsService private readonly _promptsService: IPromptsService, - @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @IAICustomizationItemsModel private readonly _itemsModel: IAICustomizationItemsModel, @IMcpService private readonly _mcpService: IMcpService, - @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, - @ISessionsManagementService private readonly _activeSessionService: ISessionsManagementService, - @IAICustomizationWorkspaceService private readonly _workspaceService: IAICustomizationWorkspaceService, - @IFileService private readonly _fileService: IFileService, @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, - @ICustomizationHarnessService private readonly _harnessService: ICustomizationHarnessService, ) { super(undefined, action, { ...options, icon: false, label: false }); this._viewItemDisposables = this._register(new DisposableStore()); @@ -149,68 +140,25 @@ export class CustomizationLinkViewItem extends ActionViewItem { // Count container (inside button, floating right) this._countContainer = append(this._button.element, $('span.customization-link-counts')); - // Subscribe to changes - this._viewItemDisposables.add(this._promptsService.onDidChangeCustomAgents(() => this._updateCounts())); - this._viewItemDisposables.add(this._promptsService.onDidChangeSlashCommands(() => this._updateCounts())); - this._viewItemDisposables.add(this._languageModelsService.onDidChangeLanguageModels(() => this._updateCounts())); this._viewItemDisposables.add(autorun(reader => { - this._mcpService.servers.read(reader); - this._updateCounts(); - })); - this._viewItemDisposables.add(autorun(reader => { - this._agentPluginService.plugins.read(reader); - this._updateCounts(); - })); - this._viewItemDisposables.add(this._workspaceContextService.onDidChangeWorkspaceFolders(() => this._updateCounts())); - this._viewItemDisposables.add(autorun(reader => { - this._activeSessionService.activeSession.read(reader); - this._harnessService.availableHarnesses.read(reader); - const provider = getActiveItemProvider(this._activeSessionService, this._harnessService); - if (provider) { - reader.store.add(provider.onDidChange(() => this._updateCounts())); + const count = this._readCount(reader); + if (this._countContainer) { + this._renderTotalCount(this._countContainer, count); } - this._updateCounts(); })); - - // Initial count - this._updateCounts(); } - private _updateCountsRequestId = 0; - - private async _updateCounts(): Promise { - if (!this._countContainer) { - return; + private _readCount(reader: Parameters[0]>[0]): number { + if (this._config.modelSection) { + return this._itemsModel.getCount(this._config.modelSection).read(reader); } - - const requestId = ++this._updateCountsRequestId; - const itemProvider = getActiveItemProvider(this._activeSessionService, this._harnessService); - - if (this._config.promptType) { - if (itemProvider) { - const allItems = await itemProvider.provideChatSessionCustomizations(CancellationToken.None); - if (requestId !== this._updateCountsRequestId) { - return; - } - const total = allItems?.filter(item => item.type === this._config.promptType).length ?? 0; - this._renderTotalCount(this._countContainer, total); - } else { - const type = this._config.promptType; - const filter = this._workspaceService.getStorageSourceFilter(type); - const counts = await getSourceCounts(this._promptsService, type, filter, this._workspaceContextService, this._workspaceService, this._fileService); - if (requestId !== this._updateCountsRequestId) { - return; - } - const total = getSourceCountsTotal(counts, filter); - this._renderTotalCount(this._countContainer, total); - } - } else if (this._config.isMcp) { - const total = this._mcpService.servers.get().length; - this._renderTotalCount(this._countContainer, total); - } else if (this._config.isPlugins) { - const total = this._agentPluginService.plugins.get().length; - this._renderTotalCount(this._countContainer, total); + if (this._config.isMcp) { + return this._mcpService.servers.read(reader).length; + } + if (this._config.isPlugins) { + return this._agentPluginService.plugins.read(reader).length; } + return 0; } private _renderTotalCount(container: HTMLElement, count: number): void { diff --git a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts index 7c06c93808544..79dbe1b675b7d 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts @@ -6,22 +6,16 @@ import { toAction } from '../../../../../base/common/actions.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { ResourceSet } from '../../../../../base/common/map.js'; -import { observableValue } from '../../../../../base/common/observable.js'; -import { URI } from '../../../../../base/common/uri.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { IActionViewItemFactory, IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; import { IMenu, IMenuActionOptions, IMenuService, isIMenuItem, MenuId, MenuItemAction, MenuRegistry, SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; -import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; -import { IPromptsService, PromptsStorage } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; -import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { IMcpServer, IMcpService } from '../../../../../workbench/contrib/mcp/common/mcpTypes.js'; -import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; -import { ICustomizationHarnessService } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { IAICustomizationItemsModel, ItemsModelSection } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; +import { AICustomizationManagementSection } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationListItem } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js'; import { CUSTOMIZATION_ITEMS, CustomizationLinkViewItem } from '../../browser/customizationsToolbar.contribution.js'; @@ -30,7 +24,6 @@ import { Menus } from '../../../../browser/menus.js'; // Ensure color registrations are loaded import '../../../../common/theme.js'; import '../../../../../platform/theme/common/colors/inputColors.js'; -import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; // ============================================================================ // One-time menu item registration (module-level). @@ -49,8 +42,6 @@ for (const [index, config] of CUSTOMIZATION_ITEMS.entries()) { // ============================================================================ // FixtureMenuService — reads from MenuRegistry without context-key filtering -// (MockContextKeyService.contextMatchesRules always returns false, which hides -// every item when using the real MenuService.) // ============================================================================ class FixtureMenuService implements IMenuService { @@ -101,51 +92,39 @@ class FixtureActionViewItemService implements IActionViewItemService { } // ============================================================================ -// Mock helpers +// Mock IAICustomizationItemsModel — controllable per-section observables. +// This is the single source of truth for counts in both the editor and +// sidebar, so the fixture only needs to mock this one service. // ============================================================================ -const defaultFilter: IStorageSourceFilter = { - sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension], -}; - -function createMockPromptsService(): IPromptsService { - return createMockPromptsServiceWithCounts(); -} - interface ICustomizationCounts { readonly agents?: number; readonly skills?: number; readonly instructions?: number; + readonly prompts?: number; readonly hooks?: number; } -function createMockPromptsServiceWithCounts(counts?: ICustomizationCounts): IPromptsService { - const fakeUri = (prefix: string, i: number) => URI.parse(`file:///mock/${prefix}-${i}.md`); - const fakeItem = (prefix: string, i: number) => ({ uri: fakeUri(prefix, i), storage: PromptsStorage.local }); - - const agents = Array.from({ length: counts?.agents ?? 0 }, (_, i) => ({ - uri: fakeUri('agent', i), - source: { storage: PromptsStorage.local }, - })); - const skills = Array.from({ length: counts?.skills ?? 0 }, (_, i) => fakeItem('skill', i)); - const instructions = Array.from({ length: counts?.instructions ?? 0 }, (_, i) => fakeItem('instructions', i)); - const hooks = Array.from({ length: counts?.hooks ?? 0 }, (_, i) => fakeItem('hook', i)); - - return new class extends mock() { - override readonly onDidChangeCustomAgents = Event.None; - override readonly onDidChangeSlashCommands = Event.None; - override readonly onDidChangeSkills = Event.None; - override readonly onDidChangeInstructions = Event.None; - override readonly onDidChangeHooks = Event.None; - override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); } - override async getInstructionFiles() { return instructions as never[]; } - override getPromptLocationLabel() { return ''; } - override async getCustomAgents() { return agents as never[]; } - override async findAgentSkills() { return skills as never[]; } - override async listPromptFiles(type: PromptsType) { - return (type === PromptsType.hook ? hooks : instructions) as never[]; +function createMockItemsModel(counts?: ICustomizationCounts): IAICustomizationItemsModel { + const fakeItems = (n: number): readonly IAICustomizationListItem[] => + Array.from({ length: n }, (): IAICustomizationListItem => Object.create(null)); + + const sectionItems = new Map>([ + [AICustomizationManagementSection.Agents, observableValue('agentsItems', fakeItems(counts?.agents ?? 0))], + [AICustomizationManagementSection.Skills, observableValue('skillsItems', fakeItems(counts?.skills ?? 0))], + [AICustomizationManagementSection.Instructions, observableValue('instructionsItems', fakeItems(counts?.instructions ?? 0))], + [AICustomizationManagementSection.Prompts, observableValue('promptsItems', fakeItems(counts?.prompts ?? 0))], + [AICustomizationManagementSection.Hooks, observableValue('hooksItems', fakeItems(counts?.hooks ?? 0))], + ]); + + return new class extends mock() { + override getItems(section: ItemsModelSection) { + return sectionItems.get(section)!; + } + override getCount(section: ItemsModelSection): IObservable { + const items = sectionItems.get(section)!; + return observableValue(`${section}-count`, items.get().length); } - override async listAgentInstructions() { return [] as never[]; } }(); } @@ -157,22 +136,6 @@ function createMockMcpService(serverCount: number = 0): IMcpService { }(); } -function createMockWorkspaceService(): IAICustomizationWorkspaceService { - const activeProjectRoot = observableValue('mockActiveProjectRoot', undefined); - return new class extends mock() { - override readonly activeProjectRoot = activeProjectRoot; - override getActiveProjectRoot() { return undefined; } - override getStorageSourceFilter() { return defaultFilter; } - }(); -} - -function createMockWorkspaceContextService(): IWorkspaceContextService { - return new class extends mock() { - override readonly onDidChangeWorkspaceFolders = Event.None; - override getWorkspace(): IWorkspace { return { id: 'test', folders: [] }; } - }(); -} - // ============================================================================ // Render helper // ============================================================================ @@ -190,32 +153,15 @@ function renderWidget(ctx: ComponentFixtureContext, options?: { mcpServerCount?: // Register overrides AFTER registerWorkbenchServices so they take priority reg.defineInstance(IMenuService, new FixtureMenuService()); reg.defineInstance(IActionViewItemService, actionViewItemService); - // Services needed by AICustomizationShortcutsWidget - reg.defineInstance(IPromptsService, options?.counts ? createMockPromptsServiceWithCounts(options.counts) : createMockPromptsService()); + reg.defineInstance(IAICustomizationItemsModel, createMockItemsModel(options?.counts)); reg.defineInstance(IMcpService, createMockMcpService(options?.mcpServerCount ?? 0)); - reg.defineInstance(IAICustomizationWorkspaceService, createMockWorkspaceService()); - reg.defineInstance(IWorkspaceContextService, createMockWorkspaceContextService()); reg.defineInstance(IAgentPluginService, new class extends mock() { override readonly plugins = observableValue('mockPlugins', []); }()); - // Additional services needed by CustomizationLinkViewItem - reg.defineInstance(ILanguageModelsService, new class extends mock() { - override readonly onDidChangeLanguageModels = Event.None; - }()); - reg.defineInstance(ISessionsManagementService, new class extends mock() { - override readonly activeSession = observableValue('activeSession', undefined); - }()); - reg.defineInstance(ICustomizationHarnessService, new class extends mock() { - override readonly availableHarnesses = observableValue('availableHarnesses', []); - override findHarnessById() { return undefined; } - }()); - reg.defineInstance(IFileService, new class extends mock() { - override readonly onDidFilesChange = Event.None; - }()); }, }); - // Register view item factories from the real CustomizationLinkViewItem (per-render, instance-scoped) + // Register view item factories from the real CustomizationLinkViewItem for (const config of CUSTOMIZATION_ITEMS) { ctx.disposableStore.add(actionViewItemService.register(Menus.SidebarCustomizations, config.id, (action, options) => { return instantiationService.createInstance(CustomizationLinkViewItem, action, options, config); @@ -236,7 +182,6 @@ function renderWidget(ctx: ComponentFixtureContext, options?: { mcpServerCount?: }()); } - // Create the widget (uses FixtureMenuService → reads MenuRegistry items registered above) ctx.disposableStore.add( instantiationService.createInstance(AICustomizationShortcutsWidget, ctx.container, undefined) ); diff --git a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts deleted file mode 100644 index 3bb2f7738ec92..0000000000000 --- a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts +++ /dev/null @@ -1,948 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { URI } from '../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { IPromptsService, PromptsStorage, IPromptPath, ILocalPromptPath, IUserPromptPath, IExtensionPromptPath, IAgentInstructionFile, AgentInstructionFileType } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; -import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; -import { IWorkspaceContextService, IWorkspace, IWorkspaceFolder, WorkbenchState } from '../../../../../platform/workspace/common/workspace.js'; -import { getSourceCounts, getSourceCountsTotal, getCustomizationTotalCount, getActiveItemProvider } from '../../browser/customizationCounts.js'; -import { IMcpService } from '../../../../../workbench/contrib/mcp/common/mcpTypes.js'; -import { Event } from '../../../../../base/common/event.js'; -import { observableValue } from '../../../../../base/common/observable.js'; -import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; -import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; - -function localFile(path: string): ILocalPromptPath { - return { uri: URI.file(path), storage: PromptsStorage.local, type: PromptsType.instructions }; -} - -function userFile(path: string): IUserPromptPath { - return { uri: URI.file(path), storage: PromptsStorage.user, type: PromptsType.instructions }; -} - -function extensionFile(path: string): IExtensionPromptPath { - return { - uri: URI.file(path), - storage: PromptsStorage.extension, - type: PromptsType.instructions, - extension: undefined!, - source: undefined!, - }; -} - -function agentInstructionFile(path: string): IAgentInstructionFile { - return { uri: URI.file(path), realPath: undefined, type: AgentInstructionFileType.agentsMd }; -} - -function makeWorkspaceFolder(path: string, name?: string): IWorkspaceFolder { - const uri = URI.file(path); - return { - uri, - name: name ?? path.split('/').pop()!, - index: 0, - toResource: (rel: string) => URI.joinPath(uri, rel), - }; -} - -function createMockPromptsService(opts: { - localFiles?: IPromptPath[]; - userFiles?: IPromptPath[]; - extensionFiles?: IPromptPath[]; - allFiles?: IPromptPath[]; - agentInstructions?: IAgentInstructionFile[]; - agents?: { name: string; uri: URI; storage: PromptsStorage }[]; - skills?: { name: string; uri: URI; storage: PromptsStorage }[]; - commands?: { name: string; uri: URI; storage: PromptsStorage; type: PromptsType }[]; -} = {}): IPromptsService { - return { - listPromptFilesForStorage: async (type: PromptsType, storage: PromptsStorage) => { - if (storage === PromptsStorage.local) { return opts.localFiles ?? []; } - if (storage === PromptsStorage.user) { return opts.userFiles ?? []; } - if (storage === PromptsStorage.extension) { return opts.extensionFiles ?? []; } - return []; - }, - listPromptFiles: async () => opts.allFiles ?? [...(opts.localFiles ?? []), ...(opts.userFiles ?? []), ...(opts.extensionFiles ?? [])], - listAgentInstructions: async () => opts.agentInstructions ?? [], - getCustomAgents: async () => (opts.agents ?? []).map(a => ({ - name: a.name, - uri: a.uri, - source: { storage: a.storage }, - })), - findAgentSkills: async () => (opts.skills ?? []).map(s => ({ - name: s.name, - uri: s.uri, - storage: s.storage, - })), - getPromptSlashCommands: async () => (opts.commands ?? []).map(c => ({ - uri: c.uri, - name: c.name, - type: c.type, - storage: c.storage, - userInvocable: true, - parsedPromptFile: undefined!, - when: undefined, - })), - getSourceFolders: async () => [], - getResolvedSourceFolders: async () => [], - onDidChangeCustomAgents: Event.None, - onDidChangeSlashCommands: Event.None, - } as unknown as IPromptsService; -} - -function createMockWorkspaceService(opts: { - activeRoot?: URI; - filter?: IStorageSourceFilter; -} = {}): IAICustomizationWorkspaceService { - const defaultFilter: IStorageSourceFilter = opts.filter ?? { - sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension], - }; - return { - _serviceBrand: undefined, - activeProjectRoot: observableValue('test', opts.activeRoot), - getActiveProjectRoot: () => opts.activeRoot, - managementSections: [], - getStorageSourceFilter: () => defaultFilter, - preferManualCreation: false, - commitFiles: async () => { }, - generateCustomization: async () => { }, - } as unknown as IAICustomizationWorkspaceService; -} - -function createMockWorkspaceContextService(folders: IWorkspaceFolder[]): IWorkspaceContextService { - return { - getWorkspace: () => ({ folders } as IWorkspace), - getWorkbenchState: () => WorkbenchState.FOLDER, - getWorkspaceFolder: () => folders[0], - onDidChangeWorkspaceFolders: Event.None, - onDidChangeWorkbenchState: Event.None, - onDidChangeWorkspaceName: Event.None, - isInsideWorkspace: () => true, - } as unknown as IWorkspaceContextService; -} - -suite('customizationCounts', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - const workspaceRoot = URI.file('/workspace'); - const workspaceFolder = makeWorkspaceFolder('/workspace'); - - suite('getSourceCountsTotal', () => { - test('sums only visible sources', () => { - const counts = { workspace: 5, user: 3, extension: 2, builtin: 0 }; - const filter: IStorageSourceFilter = { sources: [PromptsStorage.local, PromptsStorage.user] }; - assert.strictEqual(getSourceCountsTotal(counts, filter), 8); - }); - - test('returns 0 for empty sources', () => { - const counts = { workspace: 5, user: 3, extension: 2, builtin: 0 }; - const filter: IStorageSourceFilter = { sources: [] }; - assert.strictEqual(getSourceCountsTotal(counts, filter), 0); - }); - - test('sums all sources', () => { - const counts = { workspace: 5, user: 3, extension: 2, builtin: 0 }; - const filter: IStorageSourceFilter = { sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension] }; - assert.strictEqual(getSourceCountsTotal(counts, filter), 10); - }); - - test('handles single source', () => { - const counts = { workspace: 7, user: 0, extension: 0, builtin: 0 }; - const filter: IStorageSourceFilter = { sources: [PromptsStorage.local] }; - assert.strictEqual(getSourceCountsTotal(counts, filter), 7); - }); - - test('ignores plugin storage in totals (not in ISourceCounts)', () => { - const counts = { workspace: 1, user: 1, extension: 1, builtin: 0 }; - const filter: IStorageSourceFilter = { sources: [PromptsStorage.plugin] }; - assert.strictEqual(getSourceCountsTotal(counts, filter), 0); - }); - }); - - suite('getSourceCounts - instructions', () => { - test('includes agent instruction files in workspace count', async () => { - const promptsService = createMockPromptsService({ - localFiles: [ - localFile('/workspace/.github/instructions/a.instructions.md'), - ], - userFiles: [], - extensionFiles: [], - allFiles: [ - localFile('/workspace/.github/instructions/a.instructions.md'), - ], - agentInstructions: [ - agentInstructionFile('/workspace/AGENTS.md'), - agentInstructionFile('/workspace/.github/copilot-instructions.md'), - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, - PromptsType.instructions, - { sources: [PromptsStorage.local, PromptsStorage.user] }, - contextService, - workspaceService, - ); - - // 1 .instructions.md + 2 agent instruction files = 3 workspace - assert.strictEqual(counts.workspace, 3); - assert.strictEqual(counts.user, 0); - }); - - test('classifies agent instructions outside workspace as user', async () => { - const promptsService = createMockPromptsService({ - localFiles: [], - userFiles: [], - extensionFiles: [], - allFiles: [], - agentInstructions: [ - agentInstructionFile('/home/user/.claude/CLAUDE.md'), - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, - PromptsType.instructions, - { sources: [PromptsStorage.local, PromptsStorage.user] }, - contextService, - workspaceService, - ); - - assert.strictEqual(counts.workspace, 0); - assert.strictEqual(counts.user, 1); - }); - - test('agent instructions under active root classified as workspace', async () => { - // Active root might not be in getWorkspace().folders (e.g. sessions worktree), - // but should still count as workspace - const activeRoot = URI.file('/session/worktree'); - const promptsService = createMockPromptsService({ - allFiles: [], - agentInstructions: [ - agentInstructionFile('/session/worktree/AGENTS.md'), - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot }); - // No workspace folders match — but active root does - const contextService = createMockWorkspaceContextService([]); - - const counts = await getSourceCounts( - promptsService, - PromptsType.instructions, - { sources: [PromptsStorage.local, PromptsStorage.user] }, - contextService, - workspaceService, - ); - - assert.strictEqual(counts.workspace, 1); - assert.strictEqual(counts.user, 0); - }); - - test('no agent instructions returns only prompt file counts', async () => { - const promptsService = createMockPromptsService({ - allFiles: [ - localFile('/workspace/.github/instructions/a.instructions.md'), - localFile('/workspace/.github/instructions/b.instructions.md'), - ], - agentInstructions: [], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, - PromptsType.instructions, - { sources: [PromptsStorage.local] }, - contextService, - workspaceService, - ); - - assert.strictEqual(counts.workspace, 2); - }); - - test('mixed agent instructions across workspace and user', async () => { - const promptsService = createMockPromptsService({ - allFiles: [ - localFile('/workspace/.github/instructions/rules.instructions.md'), - ], - agentInstructions: [ - agentInstructionFile('/workspace/AGENTS.md'), - agentInstructionFile('/workspace/CLAUDE.md'), - agentInstructionFile('/home/user/.claude/CLAUDE.md'), - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, - PromptsType.instructions, - { sources: [PromptsStorage.local, PromptsStorage.user] }, - contextService, - workspaceService, - ); - - // 1 .instructions.md + 2 workspace agent files = 3 - assert.strictEqual(counts.workspace, 3); - // 1 user-level CLAUDE.md - assert.strictEqual(counts.user, 1); - }); - }); - - suite('getSourceCounts - agents', () => { - test('uses getCustomAgents instead of listPromptFilesForStorage', async () => { - const promptsService = createMockPromptsService({ - // listPromptFilesForStorage would return these — but agents should use getCustomAgents - localFiles: [localFile('/workspace/.github/agents/a.agent.md')], - agents: [ - { name: 'agent-a', uri: URI.file('/workspace/.github/agents/a.agent.md'), storage: PromptsStorage.local }, - { name: 'agent-b', uri: URI.file('/workspace/.github/agents/b.agent.md'), storage: PromptsStorage.local }, - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, - PromptsType.agent, - { sources: [PromptsStorage.local] }, - contextService, - workspaceService, - ); - - // Should use getCustomAgents (2), not listPromptFilesForStorage (1) - assert.strictEqual(counts.workspace, 2); - }); - - test('counts agents across storage types', async () => { - const promptsService = createMockPromptsService({ - agents: [ - { name: 'local-agent', uri: URI.file('/workspace/.github/agents/a.agent.md'), storage: PromptsStorage.local }, - { name: 'user-agent', uri: URI.file('/home/.claude/agents/b.agent.md'), storage: PromptsStorage.user }, - { name: 'ext-agent', uri: URI.file('/ext/agents/c.agent.md'), storage: PromptsStorage.extension }, - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, - PromptsType.agent, - { sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension] }, - contextService, - workspaceService, - ); - - assert.deepStrictEqual(counts, { workspace: 1, user: 1, extension: 1, builtin: 0 }); - }); - - test('empty agents returns all zeros', async () => { - const promptsService = createMockPromptsService({ agents: [] }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, PromptsType.agent, - { sources: [PromptsStorage.local, PromptsStorage.user] }, - contextService, workspaceService, - ); - - assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0, builtin: 0 }); - }); - }); - - suite('getSourceCounts - skills', () => { - test('uses findAgentSkills', async () => { - const promptsService = createMockPromptsService({ - skills: [ - { name: 'skill-a', uri: URI.file('/workspace/.github/skills/a/SKILL.md'), storage: PromptsStorage.local }, - { name: 'skill-b', uri: URI.file('/home/user/.copilot/skills/b/SKILL.md'), storage: PromptsStorage.user }, - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, - PromptsType.skill, - { sources: [PromptsStorage.local, PromptsStorage.user] }, - contextService, - workspaceService, - ); - - assert.strictEqual(counts.workspace, 1); - assert.strictEqual(counts.user, 1); - }); - - test('empty skills returns zeros', async () => { - const promptsService = createMockPromptsService({ skills: [] }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, PromptsType.skill, - { sources: [PromptsStorage.local, PromptsStorage.user] }, - contextService, workspaceService, - ); - - assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0, builtin: 0 }); - }); - - test('skills filtered by storage source filter', async () => { - const promptsService = createMockPromptsService({ - skills: [ - { name: 'skill-a', uri: URI.file('/workspace/.github/skills/a/SKILL.md'), storage: PromptsStorage.local }, - { name: 'skill-b', uri: URI.file('/home/user/.copilot/skills/b/SKILL.md'), storage: PromptsStorage.user }, - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - // Only local sources visible - const counts = await getSourceCounts( - promptsService, PromptsType.skill, - { sources: [PromptsStorage.local] }, - contextService, workspaceService, - ); - - assert.strictEqual(counts.workspace, 1); - assert.strictEqual(counts.user, 0); - }); - }); - - suite('getSourceCounts - prompts', () => { - test('uses getPromptSlashCommands and filters out skills', async () => { - const promptsService = createMockPromptsService({ - commands: [ - { name: 'my-prompt', uri: URI.file('/workspace/.github/prompts/a.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, - { name: 'my-skill', uri: URI.file('/workspace/.github/skills/b/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill }, - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, - PromptsType.prompt, - { sources: [PromptsStorage.local] }, - contextService, - workspaceService, - ); - - // Should exclude the skill command - assert.strictEqual(counts.workspace, 1); - }); - - test('counts prompts across storage types', async () => { - const promptsService = createMockPromptsService({ - commands: [ - { name: 'wp', uri: URI.file('/workspace/.github/prompts/a.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, - { name: 'up', uri: URI.file('/home/user/prompts/b.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt }, - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, PromptsType.prompt, - { sources: [PromptsStorage.local, PromptsStorage.user] }, - contextService, workspaceService, - ); - - assert.deepStrictEqual(counts, { workspace: 1, user: 1, extension: 0, builtin: 0 }); - }); - - test('all skills are excluded from prompt counts', async () => { - const promptsService = createMockPromptsService({ - commands: [ - { name: 's1', uri: URI.file('/w/s1/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill }, - { name: 's2', uri: URI.file('/w/s2/SKILL.md'), storage: PromptsStorage.user, type: PromptsType.skill }, - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, PromptsType.prompt, - { sources: [PromptsStorage.local, PromptsStorage.user] }, - contextService, workspaceService, - ); - - assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0, builtin: 0 }); - }); - }); - - suite('getSourceCounts - hooks', () => { - test('uses listPromptFiles for hooks', async () => { - const promptsService = createMockPromptsService({ - allFiles: [ - localFile('/workspace/.github/hooks/pre-commit.json'), - localFile('/workspace/.claude/settings.json'), - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, PromptsType.hook, - { sources: [PromptsStorage.local] }, - contextService, workspaceService, - ); - - assert.strictEqual(counts.workspace, 2); - }); - - test('hooks with only local source excludes user hooks', async () => { - const promptsService = createMockPromptsService({ - allFiles: [ - localFile('/workspace/.github/hooks/pre-commit.json'), - userFile('/home/user/.claude/settings.json'), - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, PromptsType.hook, - { sources: [PromptsStorage.local] }, - contextService, workspaceService, - ); - - assert.strictEqual(counts.workspace, 1); - assert.strictEqual(counts.user, 0); - }); - }); - - suite('getSourceCounts - filter', () => { - test('applies includedUserFileRoots filter', async () => { - const copilotRoot = URI.file('/home/user/.copilot'); - const promptsService = createMockPromptsService({ - allFiles: [ - localFile('/workspace/.github/instructions/a.instructions.md'), - userFile('/home/user/.copilot/instructions/b.instructions.md'), - userFile('/home/user/.vscode/instructions/c.instructions.md'), - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, - PromptsType.instructions, - { - sources: [PromptsStorage.local, PromptsStorage.user], - includedUserFileRoots: [copilotRoot], - }, - contextService, - workspaceService, - ); - - assert.strictEqual(counts.workspace, 1); - // Only the copilot file passes, not the vscode profile file - assert.strictEqual(counts.user, 1); - }); - - test('excludes storage types not in sources', async () => { - const promptsService = createMockPromptsService({ - allFiles: [ - localFile('/workspace/.github/instructions/a.instructions.md'), - extensionFile('/ext/instructions/b.instructions.md'), - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, - PromptsType.instructions, - { sources: [PromptsStorage.local] }, - contextService, - workspaceService, - ); - - assert.strictEqual(counts.workspace, 1); - assert.strictEqual(counts.extension, 0); - }); - - test('includedUserFileRoots with multiple roots', async () => { - const copilotRoot = URI.file('/home/user/.copilot'); - const claudeRoot = URI.file('/home/user/.claude'); - const promptsService = createMockPromptsService({ - allFiles: [ - userFile('/home/user/.copilot/instructions/a.instructions.md'), - userFile('/home/user/.claude/rules/b.md'), - userFile('/home/user/.vscode/instructions/c.instructions.md'), - userFile('/home/user/.agents/instructions/d.instructions.md'), - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, PromptsType.instructions, - { - sources: [PromptsStorage.local, PromptsStorage.user], - includedUserFileRoots: [copilotRoot, claudeRoot], - }, - contextService, workspaceService, - ); - - // copilot + claude pass, vscode + agents don't - assert.strictEqual(counts.user, 2); - }); - - test('undefined includedUserFileRoots shows all user files', async () => { - const promptsService = createMockPromptsService({ - allFiles: [ - userFile('/home/user/.copilot/instructions/a.instructions.md'), - userFile('/home/user/.vscode/instructions/b.instructions.md'), - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, PromptsType.instructions, - { sources: [PromptsStorage.user] }, - contextService, workspaceService, - ); - - assert.strictEqual(counts.user, 2); - }); - }); - - suite('getCustomizationTotalCount', () => { - test('sums all sections', async () => { - const promptsService = createMockPromptsService({ - agents: [ - { name: 'a', uri: URI.file('/w/a.agent.md'), storage: PromptsStorage.local }, - ], - skills: [ - { name: 's', uri: URI.file('/w/s/SKILL.md'), storage: PromptsStorage.local }, - ], - commands: [ - { name: 'p', uri: URI.file('/w/p.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, - ], - }); - const mcpService = { - servers: observableValue('test', [{ id: 'srv1' }]), - } as unknown as IMcpService; - const workspaceService = createMockWorkspaceService({ - activeRoot: URI.file('/w'), - filter: { sources: [PromptsStorage.local] }, - }); - const contextService = createMockWorkspaceContextService([makeWorkspaceFolder('/w')]); - - const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService); - - // 1 agent + 1 skill + 0 instructions + 0 hooks + 1 mcp = 3 - // (prompts are not counted in sessions) - assert.strictEqual(total, 3); - }); - - test('empty workspace returns only mcp count', async () => { - const promptsService = createMockPromptsService({}); - const mcpService = { - servers: observableValue('test', [{ id: 's1' }, { id: 's2' }]), - } as unknown as IMcpService; - const workspaceService = createMockWorkspaceService({ - filter: { sources: [PromptsStorage.local] }, - }); - const contextService = createMockWorkspaceContextService([]); - - const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService); - - assert.strictEqual(total, 2); // just 2 mcp servers - }); - - test('includes instructions with agent files in count', async () => { - const instructionFiles = [ - localFile('/w/.github/instructions/a.instructions.md'), - ]; - const promptsService = createMockPromptsService({ - allFiles: instructionFiles, - agentInstructions: [ - agentInstructionFile('/w/AGENTS.md'), - ], - }); - // Override listPromptFiles to only return files for instructions type - promptsService.listPromptFiles = async (type: PromptsType) => { - return type === PromptsType.instructions ? instructionFiles : []; - }; - const mcpService = { - servers: observableValue('test', []), - } as unknown as IMcpService; - const workspaceService = createMockWorkspaceService({ - activeRoot: URI.file('/w'), - filter: { sources: [PromptsStorage.local] }, - }); - const contextService = createMockWorkspaceContextService([makeWorkspaceFolder('/w')]); - - const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService); - - // 0 agents + 0 skills + 2 instructions (1 file + 1 AGENTS.md) + 0 prompts + 0 hooks + 0 mcp = 2 - assert.strictEqual(total, 2); - }); - }); - - suite('getActiveItemProvider', () => { - function createMockSessionsService(sessionType?: string): ISessionsManagementService { - const activeSession = observableValue( - 'test', - sessionType ? { sessionType } as IActiveSession : undefined, - ); - return { activeSession } as unknown as ISessionsManagementService; - } - - function createMockHarnessService(harnesses: { id: string; itemProvider?: ICustomizationItemProvider }[]): ICustomizationHarnessService { - return { - findHarnessById: (sessionType: string) => { - const h = harnesses.find(h => h.id === sessionType); - return h ? { id: h.id, itemProvider: h.itemProvider } as IHarnessDescriptor : undefined; - }, - } as unknown as ICustomizationHarnessService; - } - - test('returns undefined when no active session', () => { - const sessionsService = createMockSessionsService(undefined); - const harnessService = createMockHarnessService([]); - assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), undefined); - }); - - test('returns undefined when session type has no matching harness', () => { - const sessionsService = createMockSessionsService('unknown-type'); - const harnessService = createMockHarnessService([{ id: 'copilotcli' }]); - assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), undefined); - }); - - test('returns undefined when harness has no itemProvider', () => { - const sessionsService = createMockSessionsService('copilotcli'); - const harnessService = createMockHarnessService([{ id: 'copilotcli', itemProvider: undefined }]); - assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), undefined); - }); - - test('returns the itemProvider when harness exists with one', () => { - const mockProvider: ICustomizationItemProvider = { - onDidChange: Event.None, - provideChatSessionCustomizations: async () => [], - }; - const sessionsService = createMockSessionsService('claude-code'); - const harnessService = createMockHarnessService([{ id: 'claude-code', itemProvider: mockProvider }]); - assert.strictEqual(getActiveItemProvider(sessionsService, harnessService), mockProvider); - }); - }); - - suite('getCustomizationTotalCount with itemProvider', () => { - function createItemProvider(items: ICustomizationItem[]): ICustomizationItemProvider { - return { - onDidChange: Event.None, - provideChatSessionCustomizations: async (_token: CancellationToken) => items, - }; - } - - function makeItem(type: string, name: string): ICustomizationItem { - return { uri: URI.file(`/mock/${name}`), type, name, extensionId: undefined, pluginUri: undefined }; - } - - test('uses itemProvider counts when provided', async () => { - const promptsService = createMockPromptsService({}); - const mcpService = { - servers: observableValue('test', [{ id: 's1' }]), - } as unknown as IMcpService; - const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } }); - const contextService = createMockWorkspaceContextService([]); - - const provider = createItemProvider([ - makeItem('agent', 'my-agent'), - makeItem('skill', 'my-skill'), - makeItem('instructions', 'my-instruction'), - makeItem('hook', 'my-hook'), - ]); - - const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, undefined, provider); - - // 4 from provider + 1 mcp = 5 - assert.strictEqual(total, 5); - }); - - test('ignores non-prompt types from itemProvider', async () => { - const promptsService = createMockPromptsService({}); - const mcpService = { - servers: observableValue('test', []), - } as unknown as IMcpService; - const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } }); - const contextService = createMockWorkspaceContextService([]); - - const provider = createItemProvider([ - makeItem('agent', 'a'), - makeItem('unknown-type', 'x'), - makeItem('prompt', 'p'), - ]); - - const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, undefined, provider); - - // Only 'agent' matches the prompt types (agent, skill, instructions, hook) - assert.strictEqual(total, 1); - }); - - test('itemProvider returning undefined counts as zero', async () => { - const promptsService = createMockPromptsService({}); - const mcpService = { - servers: observableValue('test', [{ id: 's1' }, { id: 's2' }]), - } as unknown as IMcpService; - const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } }); - const contextService = createMockWorkspaceContextService([]); - - const provider: ICustomizationItemProvider = { - onDidChange: Event.None, - provideChatSessionCustomizations: async () => undefined, - }; - - const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, undefined, provider); - - // 0 from provider + 2 mcp = 2 - assert.strictEqual(total, 2); - }); - - test('sums itemProvider counts with plugins and mcp', async () => { - const promptsService = createMockPromptsService({}); - const mcpService = { - servers: observableValue('test', [{ id: 's1' }]), - } as unknown as IMcpService; - const workspaceService = createMockWorkspaceService({ filter: { sources: [PromptsStorage.local] } }); - const contextService = createMockWorkspaceContextService([]); - - const provider = createItemProvider([ - makeItem('agent', 'a'), - makeItem('skill', 's'), - ]); - const agentPluginService = { - plugins: observableValue('test', [{ id: 'p1' }, { id: 'p2' }, { id: 'p3' }]), - } as unknown as IAgentPluginService; - - const total = await getCustomizationTotalCount(promptsService, mcpService, workspaceService, contextService, agentPluginService, provider); - - // 2 from provider + 1 mcp + 3 plugins = 6 - assert.strictEqual(total, 6); - }); - }); - - suite('data source consistency', () => { - // These tests verify that getSourceCounts uses the same data sources - // as the list widget's loadItems() — the root cause of the count mismatch bug. - - test('instructions count matches widget: listPromptFiles + listAgentInstructions', async () => { - // Scenario: 13 .instructions.md files + 2 agent instruction files = 15 total - // The old bug: sidebar showed 13 (only listPromptFilesForStorage), - // editor showed 15 (listPromptFiles + listAgentInstructions) - const instructionFiles = Array.from({ length: 13 }, (_, i) => - localFile(`/workspace/.github/instructions/rule-${i}.instructions.md`) - ); - const promptsService = createMockPromptsService({ - localFiles: instructionFiles, - allFiles: instructionFiles, - agentInstructions: [ - agentInstructionFile('/workspace/AGENTS.md'), - agentInstructionFile('/workspace/.github/copilot-instructions.md'), - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, PromptsType.instructions, - { sources: [PromptsStorage.local, PromptsStorage.user] }, - contextService, workspaceService, - ); - - // Must be 15, not 13 - assert.strictEqual(counts.workspace, 15); - }); - - test('agents count uses getCustomAgents not listPromptFilesForStorage', async () => { - // getCustomAgents parses frontmatter and may exclude invalid files - const promptsService = createMockPromptsService({ - // Raw file count would be 3 - localFiles: [ - localFile('/workspace/.github/agents/a.agent.md'), - localFile('/workspace/.github/agents/b.agent.md'), - localFile('/workspace/.github/agents/README.md'), // would be excluded by getCustomAgents - ], - // But parsed custom agents is only 2 - agents: [ - { name: 'agent-a', uri: URI.file('/workspace/.github/agents/a.agent.md'), storage: PromptsStorage.local }, - { name: 'agent-b', uri: URI.file('/workspace/.github/agents/b.agent.md'), storage: PromptsStorage.local }, - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, PromptsType.agent, - { sources: [PromptsStorage.local] }, - contextService, workspaceService, - ); - - // Must use getCustomAgents count (2), not raw file count (3) - assert.strictEqual(counts.workspace, 2); - }); - - test('prompts count excludes skills to match widget', async () => { - // The widget's loadItems filters out skill-type commands. - // Count must do the same. - const promptsService = createMockPromptsService({ - localFiles: [ - localFile('/workspace/.github/prompts/a.prompt.md'), - localFile('/workspace/.github/prompts/b.prompt.md'), - ], - commands: [ - { name: 'prompt-a', uri: URI.file('/workspace/.github/prompts/a.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, - { name: 'prompt-b', uri: URI.file('/workspace/.github/prompts/b.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt }, - { name: 'skill-x', uri: URI.file('/workspace/.github/skills/x/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill }, - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: workspaceRoot }); - const contextService = createMockWorkspaceContextService([workspaceFolder]); - - const counts = await getSourceCounts( - promptsService, PromptsType.prompt, - { sources: [PromptsStorage.local] }, - contextService, workspaceService, - ); - - // Must be 2 (prompts only), not 3 (including skill) - assert.strictEqual(counts.workspace, 2); - }); - - test('no active root: agent instructions classified as user', async () => { - const promptsService = createMockPromptsService({ - allFiles: [], - agentInstructions: [ - agentInstructionFile('/somewhere/AGENTS.md'), - ], - }); - const workspaceService = createMockWorkspaceService({ activeRoot: undefined }); - const contextService = createMockWorkspaceContextService([]); - - const counts = await getSourceCounts( - promptsService, PromptsType.instructions, - { sources: [PromptsStorage.local, PromptsStorage.user] }, - contextService, workspaceService, - ); - - // No workspace context → classified as user - assert.strictEqual(counts.workspace, 0); - assert.strictEqual(counts.user, 1); - }); - }); -}); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index b10ee84e71a0d..cbeac560698a1 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -26,7 +26,6 @@ import { URI } from '../../../../../base/common/uri.js'; import { IChatWidgetService } from '../../../chat/browser/chat.js'; import { IChatRequestVariableEntry } from '../../../chat/common/attachments/chatVariableEntries.js'; import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; -import { ChatConfiguration } from '../../../chat/common/constants.js'; import { IElementData, IElementAncestor, BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; import { IBrowserViewModel } from '../../../browserView/common/browserView.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; @@ -42,7 +41,7 @@ import { safeSetInnerHtml } from '../../../../../base/browser/domSanitize.js'; import { BrowserActionCategory } from '../browserViewActions.js'; // Register tools -import '../tools/browserTools.contribution.js'; +import { canShareBrowserWithAgentContext } from '../tools/browserTools.contribution.js'; /** * Format an array of element ancestors into a CSS-selector-like path string. @@ -159,12 +158,6 @@ const BrowserCategory = localize2('browserCategory', "Browser"); const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active")); -const canShareBrowserWithAgentContext = ContextKeyExpr.and( - ChatContextKeys.enabled, - ContextKeyExpr.has(`config.${ChatConfiguration.AgentEnabled}`), - ContextKeyExpr.has(`config.workbench.browser.enableChatTools`), -)!; - /** * Contribution that manages element selection, element attachment to chat, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts index 4dad58cb16fff..afb62daf343fa 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts @@ -7,12 +7,15 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../nls.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; +import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { registerWorkbenchContribution2, WorkbenchPhase, type IWorkbenchContribution } from '../../../../common/contributions.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IChatContextService } from '../../../chat/browser/contextContrib/chatContextService.js'; +import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; +import { ChatConfiguration } from '../../../chat/common/constants.js'; import { ILanguageModelToolsService, ToolDataSource, ToolSet } from '../../../chat/common/tools/languageModelToolsService.js'; import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; import { formatBrowserEditorList } from './browserToolHelpers.js'; @@ -28,6 +31,14 @@ import { RunPlaywrightCodeTool, RunPlaywrightCodeToolData } from './runPlaywrigh import { ScreenshotBrowserTool, ScreenshotBrowserToolData } from './screenshotBrowserTool.js'; import { TypeBrowserTool, TypeBrowserToolData } from './typeBrowserTool.js'; + +export const canShareBrowserWithAgentContext = ContextKeyExpr.and( + ChatContextKeys.enabled, + IsSessionsWindowContext.negate(), + ContextKeyExpr.has(`config.${ChatConfiguration.AgentEnabled}`), + ContextKeyExpr.has(`config.workbench.browser.enableChatTools`), +)!; + class BrowserChatAgentToolsContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'browserView.chatAgentTools'; @@ -41,12 +52,12 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, - @IConfigurationService private readonly configurationService: IConfigurationService, @IPlaywrightService private readonly playwrightService: IPlaywrightService, @IChatContextService private readonly chatContextService: IChatContextService, @IEditorService private readonly editorService: IEditorService, @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, ) { super(); @@ -62,8 +73,9 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench this._updateToolRegistrations(); - this._register(configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('workbench.browser.enableChatTools')) { + const sharingContextKeys = new Set(canShareBrowserWithAgentContext.keys()); + this._register(contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(sharingContextKeys)) { this._updateToolRegistrations(); } })); @@ -72,7 +84,7 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench private _updateToolRegistrations(): void { this._toolsStore.clear(); - if (!this.configurationService.getValue('workbench.browser.enableChatTools')) { + if (!this.contextKeyService.contextMatchesRules(canShareBrowserWithAgentContext)) { // If chat tools are disabled, we only register the non-agentic open tool, // which allows opening browser pages without granting access to their contents. this._toolsStore.add(this.toolsService.registerTool(OpenBrowserToolNonAgenticData, this.instantiationService.createInstance(OpenBrowserToolNonAgentic))); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 44c7036efed6b..16e9c93aa2b1a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -12,10 +12,10 @@ import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, DisposableResourceMap, DisposableStore, IReference, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { autorun, derived, IObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; -import { isEqual } from '../../../../../../base/common/resources.js'; +import { extUriBiasedIgnorePathCase, isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; -import { AgentProvider, AgentSession, IAgentAttachment, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentProvider, AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { SessionTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; @@ -931,12 +931,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const turnId = request.requestId; this._clientDispatchedTurnIds.add(turnId); const cleanUpTurnId = () => this._clientDispatchedTurnIds.delete(turnId); - const attachments = this._convertVariablesToAttachments(request); - const messageAttachments: MessageAttachment[] = attachments.map(a => ({ - type: a.type, - path: a.path, - displayName: a.displayName, - })); + const messageAttachments = this._convertVariablesToAttachments(request); // If the user selected a different model since the session was created // (or since the last turn), dispatch a model change action first so the @@ -2261,9 +2256,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** Creates a new backend session and subscribes to its state. */ private async _createAndSubscribe(sessionResource: URI, model: ModelSelection | undefined, fork?: { session: URI; turnIndex: number; turnId: string }, sessionConfig?: Record, branchNameHint?: string): Promise { const config = branchNameHint ? { ...sessionConfig, [SessionConfigKey.BranchNameHint]: branchNameHint } : sessionConfig; - const workingDirectory = this._config.resolveWorkingDirectory?.(sessionResource) - ?? this._workingDirectoryResolver.resolve(sessionResource) - ?? this._workspaceContextService.getWorkspace().folders[0]?.uri; + const workingDirectory = this._resolveRequestedWorkingDirectory(sessionResource); this._logService.trace(`[AgentHost] Creating new session, model=${model?.id ?? '(default)'}, provider=${this._config.provider}${fork ? `, fork from ${fork.session.toString()} at index ${fork.turnIndex}` : ''}`); @@ -2421,23 +2414,32 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return rawModelId.startsWith(prefix) ? rawModelId : `${prefix}${rawModelId}`; } - private _convertVariablesToAttachments(request: IChatAgentRequest): IAgentAttachment[] { - const attachments: IAgentAttachment[] = []; + private _resolveRequestedWorkingDirectory(sessionResource: URI): URI | undefined { + return this._config.resolveWorkingDirectory?.(sessionResource) + ?? this._workingDirectoryResolver.resolve(sessionResource) + ?? this._workspaceContextService.getWorkspace().folders[0]?.uri; + } + + private _convertVariablesToAttachments(request: IChatAgentRequest): MessageAttachment[] { + const attachments: MessageAttachment[] = []; for (const v of request.variables.variables) { if (v.kind === 'file') { const uri = v.value instanceof URI ? v.value : undefined; if (uri?.scheme === 'file') { - attachments.push({ type: AttachmentType.File, path: uri.fsPath, displayName: v.name }); + const attachmentUri = this._rebaseAttachmentUri(uri, request.sessionResource); + attachments.push({ type: AttachmentType.File, uri: attachmentUri.toString(), displayName: v.name }); } } else if (v.kind === 'directory') { const uri = v.value instanceof URI ? v.value : undefined; if (uri?.scheme === 'file') { - attachments.push({ type: AttachmentType.Directory, path: uri.fsPath, displayName: v.name }); + const attachmentUri = this._rebaseAttachmentUri(uri, request.sessionResource); + attachments.push({ type: AttachmentType.Directory, uri: attachmentUri.toString(), displayName: v.name }); } } else if (v.kind === 'implicit' && v.isSelection) { const uri = v.uri; if (uri?.scheme === 'file') { - attachments.push({ type: AttachmentType.Selection, path: uri.fsPath, displayName: v.name }); + const attachmentUri = this._rebaseAttachmentUri(uri, request.sessionResource); + attachments.push({ type: AttachmentType.Selection, uri: attachmentUri.toString(), displayName: v.name }); } } } @@ -2447,6 +2449,42 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return attachments; } + /** + * Rebase a `file:`-scheme attachment URI from the session's requested + * working directory onto the server-resolved working directory. This + * matters on the first turn of a worktree-isolated session, where the + * provider creates a worktree under a different path than the workspace + * folder the workbench attached the file from. Returns the URI unchanged + * if the requested and resolved directories match, the URI is not under + * the requested directory, or either side is unavailable. + */ + private _rebaseAttachmentUri(uri: URI, sessionResource: URI): URI { + const requestedDir = this._resolveRequestedWorkingDirectory(sessionResource); + if (!requestedDir || requestedDir.scheme !== 'file') { + return uri; + } + const backendSession = this._sessionToBackend.get(sessionResource); + const rawResolvedDir = backendSession ? this._getSessionState(backendSession.toString())?.summary.workingDirectory : undefined; + const resolvedDir = typeof rawResolvedDir === 'string' ? URI.parse(rawResolvedDir) : rawResolvedDir; + if (!resolvedDir || resolvedDir.scheme !== 'file') { + return uri; + } + if (extUriBiasedIgnorePathCase.isEqual(requestedDir, resolvedDir)) { + return uri; + } + if (!extUriBiasedIgnorePathCase.isEqualOrParent(uri, requestedDir)) { + return uri; + } + const rel = extUriBiasedIgnorePathCase.relativePath(requestedDir, uri); + if (rel === undefined) { + return uri; + } + if (rel === '') { + return resolvedDir; + } + return URI.joinPath(resolvedDir, ...rel.split('/')); + } + // ---- Lifecycle ---------------------------------------------------------- // ---- Session subscription helpers ---------------------------------------- diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts new file mode 100644 index 0000000000000..df7e2fd237180 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts @@ -0,0 +1,276 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { onUnexpectedError } from '../../../../../base/common/errors.js'; +import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IPathService } from '../../../../services/path/common/pathService.js'; +import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; +import { ICustomizationHarnessService, ICustomizationItemProvider, IHarnessDescriptor } from '../../common/customizationHarnessService.js'; +import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; +import { AICustomizationItemNormalizer, IAICustomizationItemSource, IAICustomizationListItem, ProviderCustomizationItemSource } from './aiCustomizationItemSource.js'; +import { PromptsServiceCustomizationItemProvider } from './promptsServiceCustomizationItemProvider.js'; + +/** + * The set of sections whose items are sourced from the customization + * harness pipeline (extension-contributed providers, sync providers, + * and the prompts-service fallback). McpServers / Plugins / Models + * have their own dedicated services and are not modeled here. + */ +export const ITEMS_MODEL_SECTIONS = [ + AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Skills, + AICustomizationManagementSection.Instructions, + AICustomizationManagementSection.Prompts, + AICustomizationManagementSection.Hooks, +] as const; + +export type ItemsModelSection = typeof ITEMS_MODEL_SECTIONS[number]; + +export const IAICustomizationItemsModel = createDecorator('aiCustomizationItemsModel'); + +/** + * Single source of truth for the items rendered by the AI Customizations + * editor and observed by sidebar surfaces (counts/badges). + * + * The model owns the per-active-harness `ProviderCustomizationItemSource` + * cache and exposes the unfiltered, normalized list of items per section. + * Both the editor and any sidebar surface read from these observables so + * there is exactly one discovery path for customizations. + */ +export interface IAICustomizationItemsModel { + readonly _serviceBrand: undefined; + + /** + * Returns an observable of the unfiltered, normalized list items for the + * given prompts-based section under the currently active harness. + */ + getItems(section: ItemsModelSection): IObservable; + + /** + * Returns the live `ProviderCustomizationItemSource` for the active harness. + * Editor consumers may need this to access provider-level affordances + * (e.g. debug reporting). The returned source is reused across the + * lifetime of the active descriptor. + */ + getActiveItemSource(): IAICustomizationItemSource; + + /** + * Convenience: an observable of the count for the given section. + */ + getCount(section: ItemsModelSection): IObservable; + + /** + * The fallback item provider used when the active descriptor has neither + * an `itemProvider` nor a `syncProvider`. Exposed for the debug report. + */ + getPromptsServiceItemProvider(): ICustomizationItemProvider; + + /** + * Resolves once the most recent fetch for `section` has settled. Useful for + * tests / fixtures that need rendered output to reflect at least one fetch. + * Calling this also marks the section as observed (i.e. starts a fetch if + * none has been kicked off yet). + */ + whenSectionLoaded(section: ItemsModelSection): Promise; +} + +export class AICustomizationItemsModel extends Disposable implements IAICustomizationItemsModel { + declare readonly _serviceBrand: undefined; + + private readonly itemNormalizer: AICustomizationItemNormalizer; + private readonly promptsServiceItemProvider: PromptsServiceCustomizationItemProvider; + + /** + * Cached source per active descriptor. Keyed by descriptor reference (not id) so that + * an external harness re-registering under the same id (e.g. extension reload) gets a + * fresh source bound to the new provider. Pruned when its descriptor is no longer + * present in `availableHarnesses`. + */ + private readonly sourceCache = new Map(); + + private readonly perSection = new Map>(); + private readonly perSectionCount = new Map>(); + private readonly fetchSeq = new Map(); + /** Promise of the most recent fetch per section (resolves regardless of stale-discard). */ + private readonly perSectionPending = new Map>(); + /** + * Sections that have been observed at least once. The model only fetches on + * demand: first `getItems`/`getCount` for a section triggers an initial fetch, + * and subsequent harness/source/workspace change events refetch only sections + * that have already been read. This avoids 5x provider enumeration on startup. + */ + private readonly observedSections = new Set(); + + constructor( + @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, + @IPromptsService private readonly promptsService: IPromptsService, + @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, + @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, + @ILabelService labelService: ILabelService, + @IAgentPluginService agentPluginService: IAgentPluginService, + @IProductService productService: IProductService, + @IFileService private readonly fileService: IFileService, + @IPathService private readonly pathService: IPathService, + ) { + super(); + + this.itemNormalizer = new AICustomizationItemNormalizer( + workspaceContextService, + workspaceService, + labelService, + agentPluginService, + productService, + ); + this.promptsServiceItemProvider = new PromptsServiceCustomizationItemProvider( + () => this.harnessService.getActiveDescriptor(), + this.promptsService, + this.workspaceService, + productService, + ); + + for (const section of ITEMS_MODEL_SECTIONS) { + const items = observableValue(`aiCustomizationItems:${section}`, []); + this.perSection.set(section, items); + this.perSectionCount.set(section, derived(reader => items.read(reader).length)); + this.fetchSeq.set(section, 0); + } + + // Re-bind to the active source whenever the active harness or the set of available + // harnesses changes (a new external provider may have registered for the already- + // active id), prune the source cache, and refetch any observed sections. + const sourceChangeListener = this._register(new MutableDisposable()); + this._register(autorun(reader => { + const available = this.harnessService.availableHarnesses.read(reader); + this.harnessService.activeHarness.read(reader); + this.pruneSourceCache(available); + const descriptor = this.harnessService.getActiveDescriptor(); + const source = this.getOrCreateSource(descriptor); + sourceChangeListener.value = source.onDidChange(() => this.refetchObserved(source)); + this.refetchObserved(source); + })); + + // Workspace folder changes / active project root changes affect the items the + // prompts service surfaces (e.g. workspace vs. user classification). + this._register(workspaceContextService.onDidChangeWorkspaceFolders(() => this.refetchObserved(this.getActiveItemSource()))); + this._register(autorun(reader => { + this.workspaceService.activeProjectRoot.read(reader); + this.refetchObserved(this.getActiveItemSource()); + })); + } + + getItems(section: ItemsModelSection): IObservable { + this.markObserved(section); + return this.perSection.get(section)!; + } + + getCount(section: ItemsModelSection): IObservable { + this.markObserved(section); + return this.perSectionCount.get(section)!; + } + + getActiveItemSource(): IAICustomizationItemSource { + return this.getOrCreateSource(this.harnessService.getActiveDescriptor()); + } + + getPromptsServiceItemProvider(): ICustomizationItemProvider { + return this.promptsServiceItemProvider; + } + + whenSectionLoaded(section: ItemsModelSection): Promise { + this.markObserved(section); + return this.perSectionPending.get(section) ?? Promise.resolve(); + } + + private markObserved(section: ItemsModelSection): void { + if (this.observedSections.has(section) || this._store.isDisposed) { + return; + } + this.observedSections.add(section); + this.refetchSection(section, this.getActiveItemSource()); + } + + private getOrCreateSource(descriptor: IHarnessDescriptor): IAICustomizationItemSource { + const cached = this.sourceCache.get(descriptor); + if (cached) { + return cached; + } + const itemProvider = descriptor.itemProvider ?? (descriptor.syncProvider ? undefined : this.promptsServiceItemProvider); + const source = new ProviderCustomizationItemSource( + itemProvider, + descriptor.syncProvider, + this.promptsService, + this.workspaceService, + this.fileService, + this.pathService, + this.itemNormalizer, + ); + this.sourceCache.set(descriptor, source); + return source; + } + + private pruneSourceCache(available: readonly IHarnessDescriptor[]): void { + const live = new Set(available); + for (const descriptor of this.sourceCache.keys()) { + if (!live.has(descriptor)) { + this.sourceCache.delete(descriptor); + } + } + } + + private refetchObserved(source: IAICustomizationItemSource): void { + for (const section of this.observedSections) { + this.refetchSection(section, source); + } + } + + private refetchSection(section: ItemsModelSection, source: IAICustomizationItemSource): void { + const seq = (this.fetchSeq.get(section) ?? 0) + 1; + this.fetchSeq.set(section, seq); + const promptType = sectionToPromptType(section); + const observable = this.perSection.get(section)!; + const pending = source.fetchItems(promptType).then(items => { + if (this._store.isDisposed) { + return; + } + // Discard stale results (a newer fetch superseded this one). + if (this.fetchSeq.get(section) !== seq) { + return; + } + // Discard if the active source changed mid-fetch. + if (this.getActiveItemSource() !== source) { + return; + } + observable.set(items, undefined); + }, e => { + if (this._store.isDisposed) { + return; + } + onUnexpectedError(e); + }); + this.perSectionPending.set(section, pending); + } +} + +function sectionToPromptType(section: ItemsModelSection): PromptsType { + switch (section) { + case AICustomizationManagementSection.Agents: return PromptsType.agent; + case AICustomizationManagementSection.Skills: return PromptsType.skill; + case AICustomizationManagementSection.Instructions: return PromptsType.instructions; + case AICustomizationManagementSection.Hooks: return PromptsType.hook; + case AICustomizationManagementSection.Prompts: + default: return PromptsType.prompt; + } +} + +registerSingleton(IAICustomizationItemsModel, AICustomizationItemsModel, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 8f70afe369c7e..1edbb1d3f36b0 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -8,7 +8,6 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { ActionBar } from '../../../../../base/browser/ui/actionbar/actionbar.js'; import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { autorun } from '../../../../../base/common/observable.js'; import { isEqual } from '../../../../../base/common/resources.js'; @@ -35,7 +34,6 @@ import { Button, ButtonWithDropdown } from '../../../../../base/browser/ui/butto import { IMenuService, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { createActionViewItem, getContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { Action, Separator } from '../../../../../base/common/actions.js'; @@ -43,15 +41,13 @@ import { IClipboardService } from '../../../../../platform/clipboard/common/clip import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; -import { IPathService } from '../../../../services/path/common/pathService.js'; import { generateCustomizationDebugReport } from './aiCustomizationDebugPanel.js'; import { getCustomizationSecondaryText } from './aiCustomizationListWidgetUtils.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { AICustomizationItemNormalizer, IAICustomizationItemSource, IAICustomizationListItem, ProviderCustomizationItemSource } from './aiCustomizationItemSource.js'; -import { PromptsServiceCustomizationItemProvider } from './promptsServiceCustomizationItemProvider.js'; +import { IAICustomizationListItem } from './aiCustomizationItemSource.js'; +import { IAICustomizationItemsModel, ItemsModelSection } from './aiCustomizationItemsModel.js'; export { truncateToFirstLine } from './aiCustomizationListWidgetUtils.js'; @@ -488,6 +484,24 @@ export function sectionToPromptType(section: AICustomizationManagementSection): } } +/** + * Maps a UI section to the items-model section, or `undefined` if the + * section isn't sourced from the customization harness pipeline (e.g. + * MCP Servers, Plugins, Models — those have their own services). + */ +function toItemsModelSection(section: AICustomizationManagementSection): ItemsModelSection | undefined { + switch (section) { + case AICustomizationManagementSection.Agents: + case AICustomizationManagementSection.Skills: + case AICustomizationManagementSection.Instructions: + case AICustomizationManagementSection.Prompts: + case AICustomizationManagementSection.Hooks: + return section; + default: + return undefined; + } +} + /** * An ordered create action for the add button. */ @@ -522,18 +536,17 @@ export class AICustomizationListWidget extends Disposable { private emptyStateSubtext!: HTMLElement; private currentSection: AICustomizationManagementSection = AICustomizationManagementSection.Agents; - private allItems: IAICustomizationListItem[] = []; + private allItems: readonly IAICustomizationListItem[] = []; private displayEntries: IListEntry[] = []; private searchQuery: string = ''; private readonly collapsedGroups = new Set(); private _layoutDeferred = false; private readonly dropdownActionDisposables = this._register(new DisposableStore()); - private _loadItemsSeq = 0; private readonly delayedFilter = new Delayer(200); - private readonly itemNormalizer: AICustomizationItemNormalizer; - private readonly promptsServiceItemProvider: PromptsServiceCustomizationItemProvider; - private cachedItemSource: { descriptorId: string; source: IAICustomizationItemSource } | undefined; + + /** Subscription to the items model for the current section; refreshed on setSection. */ + private readonly currentSectionSubscription = this._register(new MutableDisposable()); private readonly _onDidSelectItem = this._register(new Emitter()); readonly onDidSelectItem: Event = this._onDidSelectItem.event; @@ -555,70 +568,32 @@ export class AICustomizationListWidget extends Disposable { @IContextMenuService private readonly contextMenuService: IContextMenuService, @IMenuService private readonly menuService: IMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ILabelService private readonly labelService: ILabelService, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @IClipboardService private readonly clipboardService: IClipboardService, @IHoverService private readonly hoverService: IHoverService, @IFileService private readonly fileService: IFileService, - @IPathService private readonly pathService: IPathService, @ITelemetryService private readonly telemetryService: ITelemetryService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, - @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @ICommandService private readonly commandService: ICommandService, - @IProductService private readonly productService: IProductService, + @IAICustomizationItemsModel private readonly itemsModel: IAICustomizationItemsModel, ) { super(); - this.itemNormalizer = new AICustomizationItemNormalizer( - this.workspaceContextService, - this.workspaceService, - this.labelService, - this.agentPluginService, - this.productService, - ); - this.promptsServiceItemProvider = new PromptsServiceCustomizationItemProvider( - () => this.harnessService.getActiveDescriptor(), - this.promptsService, - this.workspaceService, - this.productService, - ); this.element = $('.ai-customization-list-widget'); this.create(); - this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.refresh())); + // Re-render the add button when the active project root or harness changes. + // Item discovery itself is owned by the items model; we just rebind the + // per-section subscription so the UI follows whichever harness is active. this._register(autorun(reader => { this.workspaceService.activeProjectRoot.read(reader); this.updateAddButton(); - this.refresh(); - })); - - // Re-filter when the active harness changes - this._register(autorun(reader => { - this.harnessService.activeHarness.read(reader); - this.updateAddButton(); - this.refresh(); - })); - - // Refresh when available harnesses change (external provider registered/unregistered) - this._register(autorun(reader => { - this.harnessService.availableHarnesses.read(reader); - this.refresh(); })); - - // Subscribe to the active item source's onDidChange event. - // Read both activeHarness and availableHarnesses so that the - // subscription is re-established when a new provider harness - // registers (availableHarnesses changes) even if activeHarness - // was already set to the harness id from persisted state. - const itemSourceChangeDisposable = this._register(new MutableDisposable()); this._register(autorun(reader => { this.harnessService.activeHarness.read(reader); this.harnessService.availableHarnesses.read(reader); - this.cachedItemSource = undefined; - const activeDescriptor = this.harnessService.getActiveDescriptor(); - itemSourceChangeDisposable.value = this.getItemSource(activeDescriptor).onDidChange(() => this.refresh()); + this.updateAddButton(); })); - } private create(): void { @@ -826,16 +801,34 @@ export class AICustomizationListWidget extends Disposable { } /** - * Sets the current section and loads items for that section. + * Sets the current section and binds the list to the model's per-section + * observable. Returns once the initial fetch for the section has resolved + * so that callers (e.g. tests/fixtures) can rely on rendered output + * reflecting at least one fetch. */ async setSection(section: AICustomizationManagementSection): Promise { this.currentSection = section; this.updateSectionHeader(); - await this.loadItems(); - if (this._store.isDisposed) { + + const modelSection = toItemsModelSection(section); + if (!modelSection) { + this.currentSectionSubscription.clear(); + this.allItems = []; + this.filterItems(); + this._onDidChangeItemCount.fire(0); + this.updateAddButton(); return; } + + const observable = this.itemsModel.getItems(modelSection); + this.currentSectionSubscription.value = autorun(reader => { + const items = observable.read(reader); + this.allItems = items; + this.filterItems(); + this._onDidChangeItemCount.fire(items.length); + }); this.updateAddButton(); + await this.itemsModel.whenSectionLoaded(modelSection); } /** @@ -1113,79 +1106,36 @@ export class AICustomizationListWidget extends Disposable { /** * Refreshes the current section's items. + * + * Item discovery is owned by `IAICustomizationItemsModel`. This method + * pulls the current value from the model and re-renders. Callers do not + * need to invoke this in response to data change events — the per-section + * autorun bound in `setSection` already does that. */ - async refresh(): Promise { - await this.loadItems(); + refresh(): void { if (this._store.isDisposed) { return; } + this.applyItemsFromModel(); this.updateAddButton(); } - /** - * Loads items for the current section. - * Uses a sequence counter so that stale results from concurrent - * calls (e.g. overlapping autorun refreshes) are discarded. - */ - private async loadItems(): Promise { - const section = this.currentSection; - const seq = ++this._loadItemsSeq; - let items: IAICustomizationListItem[]; - try { - items = await this.fetchItemsForSection(section); - } catch (err) { - onUnexpectedError(err); - items = []; - } - - if (this._store.isDisposed || this.currentSection !== section || this._loadItemsSeq !== seq) { - return; // disposed, section changed, or a newer load started while loading - } - - this.allItems = items; + private applyItemsFromModel(): void { + const section = toItemsModelSection(this.currentSection); + this.allItems = section ? this.itemsModel.getItems(section).get() : []; this.filterItems(); - this._onDidChangeItemCount.fire(items.length); + this._onDidChangeItemCount.fire(this.allItems.length); } /** * Computes the item count for a given section without updating the display. - * Uses the same loading and filtering logic as `loadItems` for consistency. + * Reads from the items model so the count is consistent with what the + * editor and sidebar render. Returns 0 for sections not modeled here + * (McpServers / Plugins / Models — those have their own services). */ - async computeItemCountForSection(section: AICustomizationManagementSection): Promise { - const items = await this.fetchItemsForSection(section); - return items.length; - } - - /** - * Fetches and filters items for a given section. - * Delegates to the item source selected by the active harness. - */ - private async fetchItemsForSection(section: AICustomizationManagementSection): Promise { - const promptType = sectionToPromptType(section); - return this.getItemSource(this.harnessService.getActiveDescriptor()).fetchItems(promptType); - } - - /** - * Returns the rich, browser-internal item source for a harness descriptor. - * The source is cached per descriptor id and reused across fetch and - * subscription calls to avoid redundant event composition. - */ - private getItemSource(descriptor: ReturnType): IAICustomizationItemSource { - if (this.cachedItemSource && this.cachedItemSource.descriptorId === descriptor.id) { - return this.cachedItemSource.source; - } - const itemProvider = descriptor.itemProvider ?? (descriptor.syncProvider ? undefined : this.promptsServiceItemProvider); - const source = new ProviderCustomizationItemSource( - itemProvider, - descriptor.syncProvider, - this.promptsService, - this.workspaceService, - this.fileService, - this.pathService, - this.itemNormalizer, - ); - this.cachedItemSource = { descriptorId: descriptor.id, source }; - return source; + computeItemCountForSection(section: AICustomizationManagementSection): number { + const modelSection = toItemsModelSection(section); + return modelSection ? this.itemsModel.getCount(modelSection).get() : 0; } /** @@ -1194,7 +1144,7 @@ export class AICustomizationListWidget extends Disposable { /** * Applies the search query to items, returning matched items with highlight info. */ - private applySearchFilter(items: IAICustomizationListItem[]): IAICustomizationListItem[] { + private applySearchFilter(items: readonly IAICustomizationListItem[]): IAICustomizationListItem[] { if (!this.searchQuery.trim()) { return items.map(item => ({ ...item, nameMatches: undefined, descriptionMatches: undefined })); } @@ -1531,8 +1481,6 @@ export class AICustomizationListWidget extends Disposable { * Generates a debug report for the current section. */ async generateDebugReport(): Promise { - // Ensure items are loaded before capturing the snapshot - await this.loadItems(); if (this._store.isDisposed) { return ''; } @@ -1541,9 +1489,9 @@ export class AICustomizationListWidget extends Disposable { this.currentSection, this.promptsService, this.workspaceService, - { allItems: this.allItems, displayEntries: this.displayEntries }, + { allItems: this.allItems as IAICustomizationListItem[], displayEntries: this.displayEntries }, activeDescriptor, - this.promptsServiceItemProvider, + this.itemsModel.getPromptsServiceItemProvider(), ); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 877bc8e99c5d7..96f457f58f8f7 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -5,7 +5,6 @@ import './media/aiCustomizationManagement.css'; import * as DOM from '../../../../../base/browser/dom.js'; -import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; import { onUnexpectedError } from '../../../../../base/common/errors.js'; @@ -35,6 +34,7 @@ import { registerColor } from '../../../../../platform/theme/common/colorRegistr import { PANEL_BORDER } from '../../../../common/theme.js'; import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; import { AICustomizationListWidget } from './aiCustomizationListWidget.js'; +import { IAICustomizationItemsModel, ITEMS_MODEL_SECTIONS } from './aiCustomizationItemsModel.js'; import { McpListWidget } from './mcpListWidget.js'; import { PluginListWidget } from './pluginListWidget.js'; import { @@ -76,13 +76,11 @@ import { INotificationService } from '../../../../../platform/notification/commo import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { Action } from '../../../../../base/common/actions.js'; -import { McpServerEditorInput } from '../../../mcp/browser/mcpServerEditorInput.js'; -import { McpServerEditor } from '../../../mcp/browser/mcpServerEditor.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IWorkbenchMcpServer } from '../../../mcp/common/mcpTypes.js'; -import { AgentPluginEditor } from '../agentPluginEditor/agentPluginEditor.js'; -import { AgentPluginEditorInput } from '../agentPluginEditor/agentPluginEditorInput.js'; import { IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; +import { EmbeddedMcpServerDetail } from './embeddedMcpServerDetail.js'; +import { EmbeddedAgentPluginDetail } from './embeddedAgentPluginDetail.js'; import { ICustomizationHarnessService, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; import { ChatConfiguration } from '../../common/constants.js'; import { AICustomizationWelcomePage } from './aiCustomizationWelcomePage.js'; @@ -294,12 +292,12 @@ export class AICustomizationManagementEditor extends EditorPane { // Embedded MCP server detail view private mcpDetailContainer: HTMLElement | undefined; - private embeddedMcpEditor: McpServerEditor | undefined; + private embeddedMcpDetail: EmbeddedMcpServerDetail | undefined; private readonly mcpDetailDisposables = this._register(new DisposableStore()); // Embedded plugin detail view private pluginDetailContainer: HTMLElement | undefined; - private embeddedPluginEditor: AgentPluginEditor | undefined; + private embeddedPluginDetail: EmbeddedAgentPluginDetail | undefined; private readonly pluginDetailDisposables = this._register(new DisposableStore()); /** Section to restore when navigating back from plugin detail (when opened from a non-plugin section). */ private pluginDetailReturnSection: AICustomizationManagementSection | undefined; @@ -313,7 +311,6 @@ export class AICustomizationManagementEditor extends EditorPane { private welcomePage: AICustomizationWelcomePage | undefined; private readonly editorDisposables = this._register(new DisposableStore()); - private readonly promptsSectionCountScheduler = this._register(new RunOnceScheduler(() => this._doRefreshAllPromptsSectionCounts(), 100)); private _editorContentChanged = false; private _previousActiveHarnessId: string | undefined; @@ -352,6 +349,7 @@ export class AICustomizationManagementEditor extends EditorPane { @INotificationService private readonly notificationService: INotificationService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @IViewsService private readonly viewsService: IViewsService, + @IAICustomizationItemsModel private readonly itemsModel: IAICustomizationItemsModel, ) { super(AICustomizationManagementEditor.ID, group, telemetryService, themeService, storageService); @@ -457,14 +455,8 @@ export class AICustomizationManagementEditor extends EditorPane { const padding = 24; this.embeddedEditor.layout({ width: Math.max(0, width - padding), height: Math.max(0, height - editorHeaderHeight - padding) }); } - if (this.viewMode === 'mcpDetail' && this.embeddedMcpEditor) { - const backHeaderHeight = 40; - this.embeddedMcpEditor.layout(new DOM.Dimension(width, Math.max(0, height - backHeaderHeight))); - } - if (this.viewMode === 'pluginDetail' && this.embeddedPluginEditor) { - const backHeaderHeight = 40; - this.embeddedPluginEditor.layout(new DOM.Dimension(width, Math.max(0, height - backHeaderHeight))); - } + // Embedded MCP/plugin detail panes use a plain DOM widget that flows with + // the container; no explicit layout call is needed here. } }, }, Sizing.Distribute, undefined, true); @@ -598,7 +590,6 @@ export class AICustomizationManagementEditor extends EditorPane { } } this._previousActiveHarnessId = activeId; - this.refreshAllPromptsSectionCounts(); })); // When the harness selector setting is off, lock to Local harness. @@ -942,14 +933,14 @@ export class AICustomizationManagementEditor extends EditorPane { this.modelsWidget.fireItemCount(); } - // Any prompts data change → refresh ALL prompts section counts (debounced) - this.editorDisposables.add(this.promptsService.onDidChangeCustomAgents(() => this.refreshAllPromptsSectionCounts())); - this.editorDisposables.add(this.promptsService.onDidChangeSkills(() => this.refreshAllPromptsSectionCounts())); - this.editorDisposables.add(this.promptsService.onDidChangeInstructions(() => this.refreshAllPromptsSectionCounts())); - this.editorDisposables.add(this.promptsService.onDidChangeSlashCommands(() => this.refreshAllPromptsSectionCounts())); - - // Load initial counts for all sections - this.refreshAllPromptsSectionCounts(); + // Per-prompts-section autoruns: drive sidebar counts from the items model, + // the same source the editor list widget renders from. + for (const section of ITEMS_MODEL_SECTIONS) { + const observable = this.itemsModel.getCount(section); + this.editorDisposables.add(autorun(reader => { + this.updateSectionCount(section, observable.read(reader)); + })); + } // Load items for the initial section if (this.isPromptsSection(this.selectedSection)) { @@ -981,28 +972,6 @@ export class AICustomizationManagementEditor extends EditorPane { this.ensureSectionsListReflectsActiveSection(); } - /** - * Schedules a debounced refresh of all prompts-based section counts. - */ - private refreshAllPromptsSectionCounts(): void { - this.promptsSectionCountScheduler.schedule(); - } - - /** - * Performs the actual refresh of all prompts-based section counts. - * Uses the list widget's shared item-loading logic so sidebar counts - * match the per-group counts shown inside each section. - */ - private _doRefreshAllPromptsSectionCounts(): void { - for (const section of this.sections) { - if (this.isPromptsSection(section.id)) { - this.listWidget.computeItemCountForSection(section.id).then(count => { - this.updateSectionCount(section.id, count); - }, onUnexpectedError); - } - } - } - //#endregion /** @@ -1194,7 +1163,7 @@ export class AICustomizationManagementEditor extends EditorPane { await this.fileService.createFile(fileUri); await this.showEmbeddedEditor(fileUri, fileName, PromptsType.instructions, PromptsStorage.local, true); } - void this.listWidget.refresh(); + this.listWidget.refresh(); return; } @@ -1255,7 +1224,7 @@ export class AICustomizationManagementEditor extends EditorPane { } await this.commandService.executeCommand(commandId, options); - void this.listWidget.refresh(); + this.listWidget.refresh(); } /** @@ -1471,7 +1440,7 @@ export class AICustomizationManagementEditor extends EditorPane { * Refreshes the list widget. */ public refreshList(): void { - void this.listWidget.refresh(); + this.listWidget.refresh(); } /** @@ -1946,32 +1915,22 @@ export class AICustomizationManagementEditor extends EditorPane { this.goBackFromMcpDetail(); })); - // Container for the MCP server editor - const editorContainer = DOM.append(this.mcpDetailContainer, $('.mcp-detail-editor-container')); + // Container for the compact MCP detail component + const detailBody = DOM.append(this.mcpDetailContainer, $('.mcp-detail-editor-container')); - // Create the embedded MCP server editor pane - this.embeddedMcpEditor = this.editorDisposables.add(this.instantiationService.createInstance(McpServerEditor, this.group)); - this.embeddedMcpEditor.create(editorContainer); + this.embeddedMcpDetail = this.editorDisposables.add(this.instantiationService.createInstance(EmbeddedMcpServerDetail, detailBody)); } private async showEmbeddedMcpDetail(server: IWorkbenchMcpServer): Promise { - if (!this.embeddedMcpEditor) { + if (!this.embeddedMcpDetail) { return; } this.viewMode = 'mcpDetail'; this.updateContentVisibility(); - const input = this.instantiationService.createInstance(McpServerEditorInput, server); this.mcpDetailDisposables.clear(); - this.mcpDetailDisposables.add(input); - - try { - await this.embeddedMcpEditor.setInput(input, undefined, {}, CancellationToken.None); - } catch { - this.goBackFromMcpDetail(); - return; - } + this.embeddedMcpDetail.setInput(server); if (this.dimension) { this.layout(this.dimension); @@ -1980,7 +1939,7 @@ export class AICustomizationManagementEditor extends EditorPane { private goBackFromMcpDetail(): void { this.mcpDetailDisposables.clear(); - this.embeddedMcpEditor?.clearInput(); + this.embeddedMcpDetail?.clearInput(); this.viewMode = 'list'; this.updateContentVisibility(); @@ -2010,32 +1969,22 @@ export class AICustomizationManagementEditor extends EditorPane { this.goBackFromPluginDetail(); })); - // Container for the plugin editor - const editorContainer = DOM.append(this.pluginDetailContainer, $('.plugin-detail-editor-container')); + // Container for the compact plugin detail component + const detailBody = DOM.append(this.pluginDetailContainer, $('.plugin-detail-editor-container')); - // Create the embedded plugin editor pane - this.embeddedPluginEditor = this.editorDisposables.add(this.instantiationService.createInstance(AgentPluginEditor, this.group)); - this.embeddedPluginEditor.create(editorContainer); + this.embeddedPluginDetail = this.editorDisposables.add(this.instantiationService.createInstance(EmbeddedAgentPluginDetail, detailBody)); } private async showEmbeddedPluginDetail(item: IAgentPluginItem): Promise { - if (!this.embeddedPluginEditor) { + if (!this.embeddedPluginDetail) { return; } this.viewMode = 'pluginDetail'; this.updateContentVisibility(); - const input = new AgentPluginEditorInput(item); this.pluginDetailDisposables.clear(); - this.pluginDetailDisposables.add(input); - - try { - await this.embeddedPluginEditor.setInput(input, undefined, {}, CancellationToken.None); - } catch { - this.goBackFromPluginDetail(); - return; - } + this.embeddedPluginDetail.setInput(item); if (this.dimension) { this.layout(this.dimension); @@ -2055,7 +2004,7 @@ export class AICustomizationManagementEditor extends EditorPane { private goBackFromPluginDetail(): void { this.pluginDetailDisposables.clear(); - this.embeddedPluginEditor?.clearInput(); + this.embeddedPluginDetail?.clearInput(); const returnSection = this.pluginDetailReturnSection; this.pluginDetailReturnSection = undefined; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedAgentPluginDetail.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedAgentPluginDetail.ts new file mode 100644 index 0000000000000..65689af41a912 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedAgentPluginDetail.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { AgentPluginItemKind, IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; +import { extensionIcon, pluginIcon } from './aiCustomizationIcons.js'; + +const $ = DOM.$; + +/** + * Compact detail view for an agent plugin inside the AI Customizations management editor's + * split-pane host. Renders identity (icon + name + source) and description. + * + * Advanced actions (enable / disable / uninstall) remain accessible via the row's existing + * context menu, so this component intentionally stays small. + */ +export class EmbeddedAgentPluginDetail extends Disposable { + + private readonly root: HTMLElement; + private readonly iconEl: HTMLElement; + private readonly nameEl: HTMLElement; + private readonly sourceEl: HTMLElement; + private readonly descriptionEl: HTMLElement; + private readonly emptyEl: HTMLElement; + + private current: IAgentPluginItem | undefined; + + constructor( + parent: HTMLElement, + ) { + super(); + + this.root = DOM.append(parent, $('.ai-customization-embedded-detail.embedded-plugin-detail')); + + const header = DOM.append(this.root, $('.embedded-detail-header')); + this.iconEl = DOM.append(header, $('.embedded-detail-icon')); + const headerText = DOM.append(header, $('.embedded-detail-header-text')); + this.nameEl = DOM.append(headerText, $('h2.embedded-detail-name')); + this.nameEl.setAttribute('role', 'heading'); + this.sourceEl = DOM.append(headerText, $('.embedded-detail-scope')); + + this.descriptionEl = DOM.append(this.root, $('.embedded-detail-description')); + + this.emptyEl = DOM.append(this.root, $('.embedded-detail-empty')); + this.emptyEl.textContent = localize('pluginDetailEmpty', "No plugin selected."); + + this.renderItem(); + } + + get element(): HTMLElement { + return this.root; + } + + setInput(item: IAgentPluginItem): void { + this.current = item; + this.renderItem(); + } + + clearInput(): void { + this.current = undefined; + this.renderItem(); + } + + private renderItem(): void { + const item = this.current; + const hasItem = !!item; + this.emptyEl.style.display = hasItem ? 'none' : ''; + this.root.classList.toggle('is-empty', !hasItem); + if (!item) { + this.nameEl.textContent = ''; + this.sourceEl.textContent = ''; + this.descriptionEl.textContent = ''; + this.iconEl.className = 'embedded-detail-icon'; + return; + } + + this.nameEl.textContent = item.name; + + const isMarketplace = item.kind === AgentPluginItemKind.Marketplace; + const iconId = isMarketplace ? extensionIcon.id : pluginIcon.id; + this.iconEl.className = `embedded-detail-icon codicon codicon-${iconId}`; + + const sourceLabel = item.marketplace + ? (isMarketplace + ? localize('pluginSourceMarketplace', "From {0}", item.marketplace) + : localize('pluginSourceInstalled', "Installed from {0}", item.marketplace)) + : (isMarketplace + ? localize('pluginSourceMarketplaceUnknown', "Marketplace plugin") + : localize('pluginSourceLocal', "Installed plugin")); + const iconSpan = $(`span.codicon.codicon-${iconId}`); + this.sourceEl.replaceChildren(iconSpan, document.createTextNode(' ' + sourceLabel)); + + const description = (item.description || '').trim(); + this.descriptionEl.textContent = description; + this.descriptionEl.style.display = description ? '' : 'none'; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts new file mode 100644 index 0000000000000..6b9cd0f0826ac --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { localize } from '../../../../../nls.js'; +import { LocalMcpServerScope } from '../../../../services/mcp/common/mcpWorkbenchManagementService.js'; +import { IMcpWorkbenchService, IWorkbenchMcpServer } from '../../../mcp/common/mcpTypes.js'; +import { mcpServerIcon, userIcon, workspaceIcon } from './aiCustomizationIcons.js'; + +const $ = DOM.$; + +/** + * Compact detail view for an MCP server inside the AI Customizations management editor's + * split-pane host. Renders identity (icon + name + scope) and description. + * + * Advanced actions (enable / disable / uninstall / configure) remain accessible via the + * row's existing context menu, so this component intentionally stays small. + */ +export class EmbeddedMcpServerDetail extends Disposable { + + private readonly root: HTMLElement; + private readonly iconEl: HTMLElement; + private readonly nameEl: HTMLElement; + private readonly scopeEl: HTMLElement; + private readonly descriptionEl: HTMLElement; + private readonly emptyEl: HTMLElement; + + private current: IWorkbenchMcpServer | undefined; + + constructor( + parent: HTMLElement, + @IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService, + ) { + super(); + + this.root = DOM.append(parent, $('.ai-customization-embedded-detail.embedded-mcp-detail')); + + const header = DOM.append(this.root, $('.embedded-detail-header')); + this.iconEl = DOM.append(header, $('.embedded-detail-icon')); + const headerText = DOM.append(header, $('.embedded-detail-header-text')); + this.nameEl = DOM.append(headerText, $('h2.embedded-detail-name')); + this.nameEl.setAttribute('role', 'heading'); + this.scopeEl = DOM.append(headerText, $('.embedded-detail-scope')); + + this.descriptionEl = DOM.append(this.root, $('.embedded-detail-description')); + + this.emptyEl = DOM.append(this.root, $('.embedded-detail-empty')); + this.emptyEl.textContent = localize('mcpDetailEmpty', "No MCP server selected."); + + // Refresh when the underlying server changes (install state, enablement, etc.). + this._register(this.mcpWorkbenchService.onChange(server => { + if (this.current && server && server.id === this.current.id) { + this.current = server; + this.renderItem(); + } + })); + + this.renderItem(); + } + + get element(): HTMLElement { + return this.root; + } + + setInput(server: IWorkbenchMcpServer): void { + this.current = server; + this.renderItem(); + } + + clearInput(): void { + this.current = undefined; + this.renderItem(); + } + + private renderItem(): void { + const server = this.current; + const hasItem = !!server; + this.emptyEl.style.display = hasItem ? 'none' : ''; + this.root.classList.toggle('is-empty', !hasItem); + if (!server) { + this.nameEl.textContent = ''; + this.scopeEl.textContent = ''; + this.descriptionEl.textContent = ''; + this.iconEl.className = 'embedded-detail-icon'; + return; + } + + this.nameEl.textContent = server.label || server.name; + + // Icon: server.codicon (when set) is the full codicon class (e.g. "codicon-foo"); + // fall back to the standard MCP server themed icon otherwise. + this.iconEl.className = `embedded-detail-icon ${server.codicon ? `codicon ${server.codicon}` : ThemeIcon.asClassName(mcpServerIcon)}`; + + // Scope label + const scope = server.local?.scope; + const scopeInfo = describeMcpScope(scope); + if (scopeInfo) { + const scopeIcon = DOM.$(`span.codicon.codicon-${scopeInfo.icon.id}`); + this.scopeEl.replaceChildren(scopeIcon, document.createTextNode(' ' + scopeInfo.label)); + this.scopeEl.style.display = ''; + } else { + this.scopeEl.replaceChildren(); + this.scopeEl.style.display = 'none'; + } + + // Description (single line, but allow wrapping in CSS) + const description = (server.description || '').trim(); + this.descriptionEl.textContent = description; + this.descriptionEl.style.display = description ? '' : 'none'; + } +} + +function describeMcpScope(scope: LocalMcpServerScope | undefined): { label: string; icon: ThemeIcon } | undefined { + switch (scope) { + case LocalMcpServerScope.Workspace: + return { label: localize('mcpScopeWorkspace', "Workspace"), icon: workspaceIcon }; + case LocalMcpServerScope.User: + case LocalMcpServerScope.RemoteUser: + return { label: localize('mcpScopeUser', "User"), icon: userIcon }; + default: + return undefined; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index addcea886cfe4..2bc9209f652cc 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -702,11 +702,13 @@ height: 100%; display: flex; flex-direction: column; + min-height: 0; } .ai-customization-management-editor .mcp-detail-editor-container { flex: 1; - overflow: hidden; + overflow: auto; + min-height: 0; } /* Embedded plugin detail view */ @@ -714,11 +716,91 @@ height: 100%; display: flex; flex-direction: column; + min-height: 0; } .ai-customization-management-editor .plugin-detail-editor-container { flex: 1; - overflow: hidden; + overflow: auto; + min-height: 0; +} + +/* Compact embedded detail component (shared by MCP / plugin) */ +.ai-customization-management-editor .ai-customization-embedded-detail { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 24px 24px 24px; + max-width: 720px; + min-width: 0; +} + +.ai-customization-management-editor .ai-customization-embedded-detail.is-empty .embedded-detail-header, +.ai-customization-management-editor .ai-customization-embedded-detail.is-empty .embedded-detail-description { + display: none; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-header { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-icon { + flex: 0 0 auto; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + color: var(--vscode-foreground); +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-icon.codicon::before { + font-size: 28px; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-header-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-name { + margin: 0; + font-size: 18px; + line-height: 22px; + font-weight: 600; + word-break: break-word; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-scope { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-scope .codicon { + font-size: 12px; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-description { + color: var(--vscode-foreground); + font-size: 13px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} + +.ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-empty { + color: var(--vscode-descriptionForeground); + font-size: 13px; + padding: 8px 0; } /* Models section footer */ diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 25d20c456ffcb..eb5cdfe9b82e3 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -134,6 +134,7 @@ import './chatManagement/chatManagement.contribution.js'; import './aiCustomization/aiCustomizationWorkspaceService.js'; import './aiCustomization/customizationHarnessService.js'; import './aiCustomization/aiCustomizationManagement.contribution.js'; +import './aiCustomization/aiCustomizationItemsModel.js'; import { ChatOutputRendererService, IChatOutputRendererService } from './chatOutputItemRenderer.js'; import { ChatCompatibilityNotifier, ChatExtensionPointHandler } from './chatParticipant.contribution.js'; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 8d9e3d78cb8b5..9f3fdcb020388 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -79,6 +79,15 @@ class MockAgentHostService extends mock() { public disposedSessions: URI[] = []; public agents = [{ provider: 'copilot' as const, displayName: 'Agent Host - Copilot', description: 'test', requiresAuth: true }]; + /** + * If set, the next {@link createSession} call seeds the session summary's + * `workingDirectory` to this URI instead of echoing back + * `config.workingDirectory`. Used to simulate the server resolving the + * working directory to a worktree path that differs from the requested + * directory. + */ + public nextResolvedWorkingDirectory?: URI; + override async listSessions(): Promise { return [...this._sessions.values()]; } @@ -100,6 +109,7 @@ class MockAgentHostService extends mock() { status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + workingDirectory: (this.nextResolvedWorkingDirectory ?? config.workingDirectory)?.toString(), }; const state: SessionState = { ...createSessionState(summary), @@ -108,6 +118,7 @@ class MockAgentHostService extends mock() { }; this.sessionStates.set(session.toString(), state); } + this.nextResolvedWorkingDirectory = undefined; return session; } @@ -384,8 +395,8 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv return { instantiationService, agentHostService, chatAgentService }; } -function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial }) { - const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables, undefined, opts?.authServiceOverride); +function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined } }) { + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables, opts?.workingDirectoryResolver, opts?.authServiceOverride); const listController = disposables.add(instantiationService.createInstance(AgentHostSessionListController, 'agent-host-copilot', 'copilot', agentHostService, undefined, 'local')); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { @@ -1631,7 +1642,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; assert.deepStrictEqual(turnAction.userMessage.attachments, [ - { type: 'file', path: URI.file('/workspace/test.ts').fsPath, displayName: 'test.ts' }, + { type: 'file', uri: URI.file('/workspace/test.ts').toString(), displayName: 'test.ts' }, ]); })); @@ -1652,7 +1663,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; assert.deepStrictEqual(turnAction.userMessage.attachments, [ - { type: 'directory', path: URI.file('/workspace/src').fsPath, displayName: 'src' }, + { type: 'directory', uri: URI.file('/workspace/src').toString(), displayName: 'src' }, ]); })); @@ -1673,18 +1684,19 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; assert.deepStrictEqual(turnAction.userMessage.attachments, [ - { type: 'selection', path: URI.file('/workspace/foo.ts').fsPath, displayName: 'selection' }, + { type: 'selection', uri: URI.file('/workspace/foo.ts').toString(), displayName: 'selection' }, ]); })); - test('non-file URIs are skipped', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test('non-file URI variables are skipped', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const uri = URI.from({ scheme: 'untitled', path: '/foo' }); const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'check this', variables: { variables: [ - upcastPartial({ kind: 'file', id: 'v-file', name: 'untitled', value: URI.from({ scheme: 'untitled', path: '/foo' }) }), + upcastPartial({ kind: 'file', id: 'v-file', name: 'untitled', value: uri }), ], }, }); @@ -1693,7 +1705,6 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - // No attachments because it's not a file:// URI assert.strictEqual(turnAction.userMessage.attachments, undefined); })); @@ -1736,8 +1747,8 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; assert.deepStrictEqual(turnAction.userMessage.attachments, [ - { type: 'file', path: URI.file('/workspace/a.ts').fsPath, displayName: 'a.ts' }, - { type: 'directory', path: URI.file('/workspace/lib').fsPath, displayName: 'lib' }, + { type: 'file', uri: URI.file('/workspace/a.ts').toString(), displayName: 'a.ts' }, + { type: 'directory', uri: URI.file('/workspace/lib').toString(), displayName: 'lib' }, ]); })); @@ -1754,6 +1765,94 @@ suite('AgentHostChatContribution', () => { const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; assert.strictEqual(turnAction.userMessage.attachments, undefined); })); + + // ---- Working-directory rebasing ----------------------------------- + // On the first turn of a worktree-isolated session, the workbench + // resolves attachments under the original workspace folder, but the + // agent server has resolved its working directory to a freshly + // created worktree path. The handler rebases attachment URIs so the + // agent receives URIs under its own working directory. + + test('rebases file/directory/selection attachments under requested working dir onto resolved working dir', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const requestedDir = URI.file('/source'); + const resolvedDir = URI.file('/worktree'); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { + workingDirectoryResolver: { resolve: () => requestedDir }, + }); + agentHostService.nextResolvedWorkingDirectory = resolvedDir; + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'rebase me', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'a.ts', value: URI.file('/source/a.ts') }), + upcastPartial({ kind: 'directory', id: 'v-dir', name: 'lib', value: URI.file('/source/lib') }), + upcastPartial({ kind: 'implicit', id: 'v-implicit', name: 'selection', isFile: true as const, isSelection: true, uri: URI.file('/source/sub/foo.ts'), enabled: true, value: undefined }), + ], + }, + }); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.userMessage.attachments, [ + { type: 'file', uri: URI.file('/worktree/a.ts').toString(), displayName: 'a.ts' }, + { type: 'directory', uri: URI.file('/worktree/lib').toString(), displayName: 'lib' }, + { type: 'selection', uri: URI.file('/worktree/sub/foo.ts').toString(), displayName: 'selection' }, + ]); + })); + + test('does not rebase when requested and resolved working dirs match', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const dir = URI.file('/source'); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { + workingDirectoryResolver: { resolve: () => dir }, + }); + agentHostService.nextResolvedWorkingDirectory = dir; + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'no rebase', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'a.ts', value: URI.file('/source/a.ts') }), + ], + }, + }); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.userMessage.attachments, [ + { type: 'file', uri: URI.file('/source/a.ts').toString(), displayName: 'a.ts' }, + ]); + })); + + test('attachments outside the requested working dir pass through unchanged', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const requestedDir = URI.file('/source'); + const resolvedDir = URI.file('/worktree'); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { + workingDirectoryResolver: { resolve: () => requestedDir }, + }); + agentHostService.nextResolvedWorkingDirectory = resolvedDir; + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'outside', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'elsewhere.ts', value: URI.file('/elsewhere/elsewhere.ts') }), + ], + }, + }); + fire({ type: 'session/turnComplete', session, turnId } as SessionAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.userMessage.attachments, [ + { type: 'file', uri: URI.file('/elsewhere/elsewhere.ts').toString(), displayName: 'elsewhere.ts' }, + ]); + })); }); // ---- AgentHostContribution discovery --------------------------------- diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts new file mode 100644 index 0000000000000..e014c65e09cec --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts @@ -0,0 +1,194 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ResourceSet } from '../../../../../../base/common/map.js'; +import { ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { AICustomizationItemsModel } from '../../../browser/aiCustomization/aiCustomizationItemsModel.js'; +import { AICustomizationManagementSection, IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; +import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; +import { ContributionEnablementState } from '../../../common/enablement.js'; +import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; +import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; + +suite('AICustomizationItemsModel', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('basics', () => { + + let disposables: DisposableStore; + let instaService: TestInstantiationService; + + let activeHarness: ISettableObservable; + let availableHarnesses: ISettableObservable; + let descriptorA: IHarnessDescriptor; + let descriptorB: IHarnessDescriptor; + let providerA_didChange: Emitter; + let providerA_callCount: number; + let providerA_items: ICustomizationItem[]; + + function createDescriptor(id: string, provider: ICustomizationItemProvider): IHarnessDescriptor { + return { + id, + label: id, + icon: Codicon.settingsGear, + getStorageSourceFilter: (): IStorageSourceFilter => ({ sources: [PromptsStorage.local, PromptsStorage.user] }), + itemProvider: provider, + }; + } + + setup(() => { + disposables = new DisposableStore(); + providerA_didChange = disposables.add(new Emitter()); + providerA_callCount = 0; + providerA_items = []; + + const providerA: ICustomizationItemProvider = { + onDidChange: providerA_didChange.event, + provideChatSessionCustomizations: (_token: CancellationToken) => { + providerA_callCount++; + return Promise.resolve(providerA_items.slice()); + }, + }; + const providerB: ICustomizationItemProvider = { + onDidChange: Event.None, + provideChatSessionCustomizations: (_token: CancellationToken) => Promise.resolve([]), + }; + descriptorA = createDescriptor('A', providerA); + descriptorB = createDescriptor('B', providerB); + + activeHarness = observableValue('activeHarness', 'A'); + availableHarnesses = observableValue('availableHarnesses', [descriptorA, descriptorB]); + + instaService = workbenchInstantiationService({}, disposables); + + instaService.stub(IPromptsService, { + onDidChangeCustomAgents: Event.None, + onDidChangeSlashCommands: Event.None, + onDidChangeSkills: Event.None, + onDidChangeHooks: Event.None, + onDidChangeInstructions: Event.None, + listPromptFiles: async () => [], + getCustomAgents: async () => [], + findAgentSkills: async () => [], + getHooks: async () => undefined, + getInstructionFiles: async () => [], + getDisabledPromptFiles: () => new ResourceSet(), + }); + + instaService.stub(IAICustomizationWorkspaceService, { + activeProjectRoot: observableValue('test', undefined), + getActiveProjectRoot: () => undefined, + managementSections: [AICustomizationManagementSection.Agents], + isSessionsWindow: false, + welcomePageFeatures: { showGettingStartedBanner: false }, + getStorageSourceFilter: () => ({ sources: [] }), + getSkillUIIntegrations: () => new Map(), + hasOverrideProjectRoot: observableValue('test', false), + commitFiles: async () => { }, + deleteFiles: async () => { }, + generateCustomization: async () => { }, + setOverrideProjectRoot: () => { }, + clearOverrideProjectRoot: () => { }, + }); + + instaService.stub(ICustomizationHarnessService, { + activeHarness, + availableHarnesses, + setActiveHarness: (id: string) => activeHarness.set(id, undefined), + getStorageSourceFilter: () => ({ sources: [] }), + getActiveDescriptor: () => availableHarnesses.get().find(d => d.id === activeHarness.get())!, + findHarnessById: (id: string) => availableHarnesses.get().find(d => d.id === id), + registerExternalHarness: () => ({ dispose() { } }), + }); + + instaService.stub(IAgentPluginService, { + plugins: observableValue('test', []), + enablementModel: { + readEnabled: () => ContributionEnablementState.EnabledProfile, + setEnabled: () => { }, + remove: () => { }, + }, + }); + }); + + teardown(() => disposables.dispose()); + + test('exposes per-section observables for all prompts-based sections', () => { + const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); + assert.ok(model.getItems(AICustomizationManagementSection.Agents)); + assert.ok(model.getItems(AICustomizationManagementSection.Skills)); + assert.ok(model.getItems(AICustomizationManagementSection.Instructions)); + assert.ok(model.getItems(AICustomizationManagementSection.Prompts)); + assert.ok(model.getItems(AICustomizationManagementSection.Hooks)); + }); + + test('does not fetch on construction (lazy)', async () => { + disposables.add(instaService.createInstance(AICustomizationItemsModel)); + await timeout(0); + assert.strictEqual(providerA_callCount, 0); + }); + + test('first read of a section triggers a fetch', async () => { + const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); + model.getItems(AICustomizationManagementSection.Agents); + await timeout(0); + assert.strictEqual(providerA_callCount, 1); + // Reading a different section triggers a separate fetch for that section only. + model.getItems(AICustomizationManagementSection.Skills); + await timeout(0); + assert.strictEqual(providerA_callCount, 2); + }); + + test('source.onDidChange refetches only previously-observed sections', async () => { + const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); + model.getItems(AICustomizationManagementSection.Agents); + await timeout(0); + const before = providerA_callCount; + providerA_didChange.fire(); + await timeout(0); + // One refetch for the one observed section — not 5. + assert.strictEqual(providerA_callCount, before + 1); + }); + + test('switching harness re-binds and refetches observed sections', async () => { + const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); + model.getItems(AICustomizationManagementSection.Agents); + await timeout(0); + const sourceA = model.getActiveItemSource(); + activeHarness.set('B', undefined); + await timeout(0); + const sourceB = model.getActiveItemSource(); + assert.notStrictEqual(sourceA, sourceB); + }); + + test('source cache is keyed by descriptor identity (not id) — re-registration produces a fresh source', async () => { + const model = disposables.add(instaService.createInstance(AICustomizationItemsModel)); + model.getItems(AICustomizationManagementSection.Agents); + await timeout(0); + const sourceA1 = model.getActiveItemSource(); + + // Replace descriptor A with a fresh descriptor that re-uses the same id. + const replacementProvider: ICustomizationItemProvider = { + onDidChange: Event.None, + provideChatSessionCustomizations: async () => [], + }; + const replacementA = createDescriptor('A', replacementProvider); + availableHarnesses.set([replacementA, descriptorB], undefined); + await timeout(0); + + const sourceA2 = model.getActiveItemSource(); + assert.notStrictEqual(sourceA1, sourceA2); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts index fb38770250231..2cc24b6fe8581 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { DeferredPromise } from '../../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Event } from '../../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; @@ -14,9 +13,10 @@ import { ICommandService } from '../../../../../../platform/commands/common/comm import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { AICustomizationListWidget } from '../../../browser/aiCustomization/aiCustomizationListWidget.js'; +import { IAICustomizationItemsModel } from '../../../browser/aiCustomization/aiCustomizationItemsModel.js'; import { extractExtensionIdFromPath, getCustomizationSecondaryText, truncateToFirstLine } from '../../../browser/aiCustomization/aiCustomizationListWidgetUtils.js'; import { AICustomizationManagementSection, IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; -import { ICustomizationHarnessService, ICustomizationItem, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; import { ContributionEnablementState } from '../../../common/enablement.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; @@ -131,35 +131,24 @@ suite('aiCustomizationListWidget', () => { }); }); - suite('dispose-during-async guards', () => { + suite('disposed widget', () => { let disposables: DisposableStore; let instaService: TestInstantiationService; - let fetchDeferred: DeferredPromise; - let fetchStarted: DeferredPromise; - function createMockHarnessDescriptor(): IHarnessDescriptor { - return { - id: 'test', - label: 'Test', - icon: Codicon.settingsGear, - getStorageSourceFilter: (): IStorageSourceFilter => ({ sources: [PromptsStorage.local, PromptsStorage.user] }), - itemProvider: { - onDidChange: Event.None, - provideChatSessionCustomizations: (_token: CancellationToken) => { - fetchStarted.complete(); - return fetchDeferred.p; - }, - }, - }; - } + const descriptor: IHarnessDescriptor = { + id: 'test', + label: 'Test', + icon: Codicon.settingsGear, + getStorageSourceFilter: (): IStorageSourceFilter => ({ sources: [PromptsStorage.local, PromptsStorage.user] }), + itemProvider: { + onDidChange: Event.None, + provideChatSessionCustomizations: (_token: CancellationToken) => Promise.resolve(undefined), + }, + }; setup(() => { disposables = new DisposableStore(); - fetchDeferred = new DeferredPromise(); - fetchStarted = new DeferredPromise(); - const descriptor = createMockHarnessDescriptor(); - instaService = workbenchInstantiationService({}, disposables); instaService.stub(IPromptsService, { @@ -216,55 +205,24 @@ suite('aiCustomizationListWidget', () => { onWillExecuteCommand: Event.None, onDidExecuteCommand: Event.None, }); - }); - - teardown(() => { - // Resolve any pending deferred to avoid hanging promises. - if (!fetchDeferred.isSettled) { - fetchDeferred.complete(undefined); - } - disposables.dispose(); - }); - - test('refresh does not throw when disposed during loadItems', async () => { - const widget = disposables.add(instaService.createInstance(AICustomizationListWidget)); - - // Start refresh — loadItems will await fetchItemsForSection - // which blocks on our deferred - const refreshPromise = widget.refresh(); - - // Wait until the provider is actually called before disposing - await fetchStarted.p; - widget.dispose(); - // Resolve the deferred — this should not cause an error - // because the disposal guard prevents updateAddButton() from running - fetchDeferred.complete(undefined); - await refreshPromise; + // The widget reads items from the items model; stub it with empty + // per-section observables. This avoids needing to wire up the full + // ProviderCustomizationItemSource pipeline in tests. + instaService.stub(IAICustomizationItemsModel, { + getItems: () => observableValue('test', [] as readonly never[]), + getCount: () => observableValue('test', 0), + getActiveItemSource: () => ({ onDidChange: Event.None, fetchItems: async () => [] }), + getPromptsServiceItemProvider: () => ({ onDidChange: Event.None, provideChatSessionCustomizations: async () => undefined }), + }); }); - test('setSection does not throw when disposed during loadItems', async () => { - const widget = disposables.add(instaService.createInstance(AICustomizationListWidget)); - - const setSectionPromise = widget.setSection(AICustomizationManagementSection.Instructions); - - await fetchStarted.p; - widget.dispose(); - - fetchDeferred.complete(undefined); - await setSectionPromise; - }); + teardown(() => disposables.dispose()); - test('generateDebugReport returns empty string when disposed during loadItems', async () => { + test('generateDebugReport returns empty string when widget is disposed', async () => { const widget = disposables.add(instaService.createInstance(AICustomizationListWidget)); - - const reportPromise = widget.generateDebugReport(); - - await fetchStarted.p; widget.dispose(); - - fetchDeferred.complete(undefined); - const result = await reportPromise; + const result = await widget.generateDebugReport(); assert.strictEqual(result, ''); }); }); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts index d668b9522f483..29edd802d2cc1 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts @@ -19,6 +19,7 @@ import { IChatSessionsService, SessionType } from '../../../../contrib/chat/comm import { PromptsType } from '../../../../contrib/chat/common/promptSyntax/promptTypes.js'; import { IPromptsService, AgentInstructionFileType, PromptsStorage, IPromptPath, IAgentInstructionFile } from '../../../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { AICustomizationManagementSection } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { AICustomizationItemsModel, IAICustomizationItemsModel } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; import { AICustomizationListWidget } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationListWidget.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; @@ -62,6 +63,9 @@ function createMockPromptsService(instructionFiles: IFixtureInstructionFile[], a } override async listAgentInstructions() { return agentInstructionFiles; } override async getCustomAgents() { return []; } + override async findAgentSkills() { return []; } + override async getPromptSlashCommands() { return []; } + override async getHooks() { return undefined; } override async getInstructionFiles() { return instructionFiles.map(f => ({ uri: f.promptPath.uri, @@ -108,6 +112,7 @@ function createMockWorkspaceService(): IAICustomizationWorkspaceService { override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); override getActiveProjectRoot() { return URI.file('/workspace'); } override getStorageSourceFilter() { return defaultFilter; } + override getSkillUIIntegrations() { return new Map(); } }(); } @@ -184,6 +189,10 @@ async function renderInstructionsTab(ctx: ComponentFixtureContext, instructionFi override userHome(): Promise; override userHome(): URI | Promise { return URI.file('/home/dev'); } }()); + // AICustomizationItemsModel is the single source of truth for items + // in the editor. Register the real implementation — it will resolve + // items via the mock prompts service / harness service above. + reg.define(IAICustomizationItemsModel, AICustomizationItemsModel); }, }); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 0f1e585f29aab..e55f8ca5edbcc 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -41,10 +41,14 @@ import { IPluginMarketplaceService, IMarketplacePlugin, MarketplaceType, PluginS import { MarketplaceReferenceKind } from '../../../../contrib/chat/common/plugins/marketplaceReference.js'; import { IPluginInstallService } from '../../../../contrib/chat/common/plugins/pluginInstallService.js'; import { AICustomizationManagementEditor } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; +import { EmbeddedMcpServerDetail } from '../../../../contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.js'; +import { EmbeddedAgentPluginDetail } from '../../../../contrib/chat/browser/aiCustomization/embeddedAgentPluginDetail.js'; +import { AgentPluginItemKind, IAgentPluginItem } from '../../../../contrib/chat/browser/agentPluginEditor/agentPluginItems.js'; import { ContributionEnablementState } from '../../../../contrib/chat/common/enablement.js'; import { AICustomizationManagementEditorInput } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { IConfigurationService, IConfigurationValue } from '../../../../../platform/configuration/common/configuration.js'; import { mcpAccessConfig, McpAccessValue } from '../../../../../platform/mcp/common/mcpManagement.js'; +import { McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { ChatConfiguration } from '../../../../contrib/chat/common/constants.js'; import { IMcpWorkbenchService, IWorkbenchMcpServer, IMcpService, McpServerInstallState } from '../../../../contrib/mcp/common/mcpTypes.js'; import { IMcpRegistry } from '../../../../contrib/mcp/common/mcpRegistryTypes.js'; @@ -195,17 +199,21 @@ function createMockHarnessService(activeHarnessId: string, descriptors: readonly override getActiveDescriptor() { return descriptors.find(h => h.id === active.get()) ?? descriptors[0]; } + override findHarnessById(id: string) { + return descriptors.find(h => h.id === id); + } override setActiveHarness(id: string) { active.set(id, undefined); } override registerExternalHarness() { return { dispose() { } }; } }(); } -function makeLocalMcpServer(id: string, label: string, scope: LocalMcpServerScope, description?: string): IWorkbenchMcpServer { +function makeLocalMcpServer(id: string, label: string, scope: LocalMcpServerScope, description?: string, config?: IWorkbenchMcpServer['config']): IWorkbenchMcpServer { return new class extends mock() { override readonly id = id; override readonly name = id; override readonly label = label; override readonly description = description ?? ''; + override readonly config = config; override readonly installState = McpServerInstallState.Installed; override readonly local = new class extends mock() { override readonly id = id; @@ -327,6 +335,17 @@ const agentInstructions: IAgentInstructionFile[] = [ ]; const mcpWorkspaceServers = [ + makeLocalMcpServer( + 'component-explorer', + 'component-explorer', + LocalMcpServerScope.Workspace, + 'Component fixtures and screenshot tooling', + { + type: McpServerType.LOCAL, + command: 'npm', + args: ['exec', '--no', '--', 'component-explorer', 'mcp', '-p', './test/componentFixtures/component-explorer.json', '--use-daemon', '-vv'], + } + ), makeLocalMcpServer('mcp-postgres', 'PostgreSQL', LocalMcpServerScope.Workspace, 'Database access'), makeLocalMcpServer('mcp-github', 'GitHub', LocalMcpServerScope.Workspace, 'GitHub API'), makeLocalMcpServer('mcp-redis', 'Redis', LocalMcpServerScope.Workspace, 'In-memory data store'), @@ -580,7 +599,7 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor if (options.openFirstItem) { const visibleContent = [...ctx.container.querySelectorAll('.prompts-content-container, .mcp-content-container, .plugin-content-container')] .find(node => node instanceof HTMLElement && node.style.display !== 'none') as HTMLElement | undefined; - const firstRow = visibleContent?.querySelector('.monaco-list-row') as HTMLElement | undefined; + const firstRow = visibleContent?.querySelector('.monaco-list-row.ai-customization-list-item, .monaco-list-row.mcp-server-item') as HTMLElement | undefined; if (firstRow) { firstRow.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 })); firstRow.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })); @@ -938,6 +957,96 @@ function renderPluginDisabled(ctx: ComponentFixtureContext, byPolicy: boolean): widget.layout(height, width); } +// ============================================================================ +// Embedded compact detail widgets — standalone (no host editor) +// ============================================================================ + +function renderEmbeddedMcpDetail(ctx: ComponentFixtureContext, server: IWorkbenchMcpServer | undefined): void { + const width = 480; + const height = 320; + ctx.container.style.width = `${width}px`; + ctx.container.style.height = `${height}px`; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + reg.defineInstance(IMcpWorkbenchService, new class extends mock() { + override readonly onChange = Event.None; + override readonly onReset = Event.None; + override readonly local: IWorkbenchMcpServer[] = server ? [server] : []; + override async open() { /* no-op in fixture */ } + }()); + }, + }); + + // Mirror the host editor's class so the scoped CSS selectors apply. + const host = DOM.append(ctx.container, DOM.$('.ai-customization-management-editor')); + host.style.height = '100%'; + host.style.width = '100%'; + host.style.overflow = 'auto'; + + const detail = ctx.disposableStore.add(instantiationService.createInstance(EmbeddedMcpServerDetail, host)); + if (server) { + detail.setInput(server); + } +} + +function renderEmbeddedPluginDetail(ctx: ComponentFixtureContext, item: IAgentPluginItem | undefined): void { + const width = 480; + const height = 320; + ctx.container.style.width = `${width}px`; + ctx.container.style.height = `${height}px`; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + }, + }); + + const host = DOM.append(ctx.container, DOM.$('.ai-customization-management-editor')); + host.style.height = '100%'; + host.style.width = '100%'; + host.style.overflow = 'auto'; + + const detail = ctx.disposableStore.add(instantiationService.createInstance(EmbeddedAgentPluginDetail, host)); + if (item) { + detail.setInput(item); + } +} + +function makeInstalledPluginItem(name: string, description: string): IAgentPluginItem { + return { + kind: AgentPluginItemKind.Installed, + name, + description, + marketplace: 'GitHub', + plugin: makeInstalledPlugin(name, URI.file(`/workspace/.copilot/plugins/${name.toLowerCase()}`), true), + }; +} + +function makeMarketplacePluginItem(name: string, description: string): IAgentPluginItem { + return { + kind: AgentPluginItemKind.Marketplace, + name, + description, + source: 'GitHub', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: `acme/${name.toLowerCase()}` }, + marketplace: 'GitHub', + marketplaceType: MarketplaceType.Copilot, + marketplaceReference: { + rawValue: `acme/${name.toLowerCase()}`, + displayLabel: `acme/${name.toLowerCase()}`, + cloneUrl: `https://github.com/acme/${name.toLowerCase()}`, + canonicalId: `github:acme/${name.toLowerCase()}`, + cacheSegments: ['github', 'acme', name.toLowerCase()], + kind: MarketplaceReferenceKind.GitHubShorthand, + githubRepo: `acme/${name.toLowerCase()}`, + }, + }; +} + // ============================================================================ // Fixtures // ============================================================================ @@ -1183,6 +1292,19 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), + // MCP server detail view in a narrow viewport — catches embedded header overflow + // and the single-tab configuration layout used by local workspace servers. + McpServerDetailNarrow: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harnessId: SessionType.Local, + selectedSection: AICustomizationManagementSection.McpServers, + openFirstItem: true, + width: 550, + height: 400, + }), + }), + // Plugin detail view — same alignment check for the detail back button. PluginDetail: defineComponentFixture({ labels: { kind: 'screenshot' }, @@ -1192,4 +1314,52 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { openFirstItem: true, }), }), + + PluginDetailNarrow: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harnessId: SessionType.Local, + selectedSection: AICustomizationManagementSection.Plugins, + openFirstItem: true, + width: 550, + height: 400, + }), + }), + + // Standalone embedded MCP detail widget (compact split-pane component). + // Workspace-scope server with a description. + EmbeddedMcpDetailWorkspace: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEmbeddedMcpDetail(ctx, makeLocalMcpServer('mcp-postgres', 'PostgreSQL', LocalMcpServerScope.Workspace, 'Database access for the active workspace')), + }), + + // Standalone embedded MCP detail widget — user-scope server. + EmbeddedMcpDetailUser: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEmbeddedMcpDetail(ctx, makeLocalMcpServer('mcp-web-search', 'Web Search', LocalMcpServerScope.User, 'Search the web from any session')), + }), + + // Standalone embedded MCP detail widget — empty / no input state. + EmbeddedMcpDetailEmpty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEmbeddedMcpDetail(ctx, undefined), + }), + + // Standalone embedded plugin detail widget — installed plugin. + EmbeddedPluginDetailInstalled: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEmbeddedPluginDetail(ctx, makeInstalledPluginItem('Linear', 'Issue tracking and project management integration')), + }), + + // Standalone embedded plugin detail widget — marketplace plugin. + EmbeddedPluginDetailMarketplace: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEmbeddedPluginDetail(ctx, makeMarketplacePluginItem('Sentry', 'Error monitoring and performance tracing')), + }), + + // Standalone embedded plugin detail widget — empty / no input state. + EmbeddedPluginDetailEmpty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEmbeddedPluginDetail(ctx, undefined), + }), });