diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index f8d91f3aaea88d..708d7481967c88 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -24,16 +24,18 @@ import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportK export type AgentHostUserMessageSentSource = 'direct' | 'queued'; /** - * Who produced the message that started a turn. Extends the protocol's - * {@link MessageKind} with `agentMerge`: Agent Merge drives its repair turns - * with a host-generated message that carries the `systemNotification` origin, - * and reporting those under their own value keeps automated merge work - * separable from turns a person or an agent asked for. + * The message origin used for telemetry. Extends the protocol's + * {@link MessageKind} with host-owned classifications for Agent Merge repair + * turns and VS Code sessions marked ephemeral, which telemetry reports as + * `inline`. */ -export type AgentHostMessageOriginTelemetryKind = MessageKind | 'agentMerge'; +export type AgentHostMessageOriginTelemetryKind = MessageKind | 'agentMerge' | 'inline'; -/** Classifies the actor that produced a turn's message for telemetry. */ -export function getMessageOriginTelemetryKind(message: Message): AgentHostMessageOriginTelemetryKind { +/** Classifies a turn's message origin, including host-owned session classifications. */ +export function getMessageOriginTelemetryKind(message: Message, isEphemeralSession: boolean): AgentHostMessageOriginTelemetryKind { + if (isEphemeralSession) { + return 'inline'; + } // The marker only counts on the origin the host stamps it with, so a client // cannot dress a user message up as automated merge work. if (message.origin.kind === MessageKind.SystemNotification && isAgentMergeMessage(message)) { @@ -123,7 +125,7 @@ export type IAgentHostUserMessageSentClassification = IAgentHostCopilotSkuClassi initiatorDevDeviceId?: { classification: 'EndUserPseudonymizedInformation'; purpose: 'BusinessInsight'; endpoint: 'SqmMachineId'; comment: 'The initiating VS Code client development device identifier.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the message was sent directly or from the queued-message flow.' }; - messageOriginKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The kind of actor that produced the message: a user, an agent (session orchestration tools such as create_session/send_message), Agent Merge, a tool, an automation, or a system notification.' }; + messageOriginKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The message origin: a user, an agent (session orchestration tools such as create_session/send_message), Agent Merge, an inline session (derived from the VS Code ephemeral-session marker), a tool, an automation, or a system notification.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the message was sent to a subagent session.' }; turnCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of completed turns in the session when the message was sent.' }; activeClientId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the first active client for the session, if any.' }; @@ -249,7 +251,7 @@ export type IAgentHostTurnCompletedClassification = IAgentHostEventClassificatio isBYOK: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the selected model is a bring-your-own-key model, when model context is available.' }; permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The tool auto-approval level configured for the session at turn start (e.g. default, autoApprove, autopilot).' }; interactionMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host interaction mode configured at turn start.' }; - messageOriginKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The kind of actor that started the turn: a user, an agent (session orchestration tools such as create_session/create_chat/send_message), Agent Merge, a tool, an automation, or a system notification.' }; + messageOriginKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The message origin that started the turn: a user, an agent (session orchestration tools such as create_session/create_chat/send_message), Agent Merge, an inline session (derived from the VS Code ephemeral-session marker), a tool, an automation, or a system notification.' }; errorType: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The structured agent host or provider error type when the turn fails.' }; failureStage: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded stage at which the agent host turn failed.' }; isMultiRoot: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the session spans more than one working directory.' }; @@ -270,6 +272,7 @@ export interface IAgentHostTurnFailedEvent extends IAgentHostEventTelemetry { chatSessionId: string; isSubagentSession: boolean; turnId: string; + messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined; failureStage: AgentHostTurnFailureStage; errorType: string; errorName: string | undefined; @@ -286,6 +289,7 @@ export type IAgentHostTurnFailedClassification = IAgentHostEventClassification & chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the failed turn belongs to a subagent session.' }; turnId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The identifier of the failed turn within the agent host session.' }; + messageOriginKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The kind of actor or host-owned session classification that started the failed turn.' }; failureStage: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded stage at which the agent host turn failed.' }; errorType: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The structured agent host or provider error type.' }; errorName: { classification: 'CallstackOrException'; purpose: 'PerformanceAndHealth'; comment: 'The name of the exception, when available.' }; @@ -406,6 +410,7 @@ export interface IAgentHostTurnHungEvent extends IAgentHostEventTelemetry { chatSessionId: string; isSubagentSession: boolean; turnId: string; + messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined; hangReason: AgentHostTurnHangReason; isExpected: boolean; hadAnyProgress: boolean; @@ -433,6 +438,7 @@ export type IAgentHostTurnHungClassification = IAgentHostEventClassification & { chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the hung turn belongs to a subagent session.' }; turnId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The identifier of the hung turn within the agent host session.' }; + messageOriginKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The kind of actor or host-owned session classification that started the hung turn.' }; hangReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The bounded state the turn was quiet in: noProgress, stalledAfterProgress, waitingOnUser, or runningTool.' }; isExpected: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the quiet period is explained by a legitimate wait (blocked on the user or running a tool) rather than an unexplained hang.' }; hadAnyProgress: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether any turn activity at all was observed before the watchdog fired.' }; @@ -460,6 +466,7 @@ export interface IAgentHostTurnHungReport extends IAgentHostTurnAttributedReport provider: string; session: string; turnId: string; + messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined; hangReason: AgentHostTurnHangReason; hadAnyProgress: boolean; lastActivityKind: string; @@ -485,6 +492,7 @@ export interface IAgentHostHungTurnCompletedEvent extends IAgentHostEventTelemet chatSessionId: string; isSubagentSession: boolean; turnId: string; + messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined; hangReason: AgentHostTurnHangReason; result: AgentHostTurnResult; hangReportCount: number; @@ -498,6 +506,7 @@ export type IAgentHostHungTurnCompletedClassification = IAgentHostEventClassific chatSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The chat identifier within the agent host session.' }; isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the recovered turn belongs to a subagent session.' }; turnId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The identifier of the recovered turn within the agent host session.' }; + messageOriginKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The kind of actor or host-owned session classification that started the recovered turn.' }; hangReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The most recently reported hang reason for the turn before it completed.' }; result: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the previously hung turn eventually completed successfully, with an error, or was cancelled.' }; hangReportCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of hang reports emitted for the turn before it completed.' }; @@ -511,6 +520,7 @@ export interface IAgentHostHungTurnCompletedReport extends IAgentHostTurnAttribu provider: string; session: string; turnId: string; + messageOriginKind: AgentHostMessageOriginTelemetryKind | undefined; hangReason: AgentHostTurnHangReason; result: AgentHostTurnResult; hangReportCount: number; @@ -886,7 +896,7 @@ export class AgentHostTelemetryReporter { }); } - userMessageSent(provider: string, clientId: string | undefined, clientContext: IAgentHostClientTelemetryContext, session: string, turnId: string, sessionState: ISessionWithDefaultChat | undefined, source: AgentHostUserMessageSentSource, message: Message): void { + userMessageSent(provider: string, clientId: string | undefined, clientContext: IAgentHostClientTelemetryContext, session: string, turnId: string, sessionState: ISessionWithDefaultChat | undefined, source: AgentHostUserMessageSentSource, message: Message, isEphemeralSession: boolean): void { const attachmentCount = message.attachments?.length ?? 0; const activeClients = sessionState?.activeClients ?? []; const sessionUri = isAhpChatChannel(session) ? parseRequiredSessionUriFromChatUri(session) : session; @@ -901,7 +911,7 @@ export class AgentHostTelemetryReporter { ...(clientContext.devDeviceId ? { initiatorDevDeviceId: clientContext.devDeviceId } : {}), agentSessionId: AgentSession.id(sessionUri), source, - messageOriginKind: getMessageOriginTelemetryKind(message), + messageOriginKind: getMessageOriginTelemetryKind(message, isEphemeralSession), isSubagentSession: isSubagentSession(sessionUri), turnCount: sessionState?.turns.length ?? 0, ...(activeClients.length > 0 ? { @@ -916,7 +926,7 @@ export class AgentHostTelemetryReporter { initiatorClientType: clientContext.clientType, conversationId: AgentSession.id(sessionUri), turnId, - messageOriginKind: getMessageOriginTelemetryKind(message), + messageOriginKind: getMessageOriginTelemetryKind(message, isEphemeralSession), }); } @@ -1270,6 +1280,7 @@ export class AgentHostTelemetryReporter { chatSessionId, isSubagentSession: isSubagent, turnId: report.turnId, + messageOriginKind: report.messageOriginKind, failureStage: report.failure.stage, errorType: report.failure.error.errorType, errorName: report.failure.errorName, @@ -1296,6 +1307,7 @@ export class AgentHostTelemetryReporter { chatSessionId: getTelemetryChatSessionId(report.session), isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), turnId: report.turnId, + messageOriginKind: report.messageOriginKind, hangReason: report.hangReason, isExpected: report.hangReason === 'waitingOnUser' || report.hangReason === 'runningTool', hadAnyProgress: report.hadAnyProgress, @@ -1330,6 +1342,7 @@ export class AgentHostTelemetryReporter { chatSessionId: getTelemetryChatSessionId(report.session), isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), turnId: report.turnId, + messageOriginKind: report.messageOriginKind, hangReason: report.hangReason, result: report.result, hangReportCount: report.hangReportCount, diff --git a/src/vs/platform/agentHost/node/agentHostTurnStarter.ts b/src/vs/platform/agentHost/node/agentHostTurnStarter.ts index 8c0987167dc7b2..0f1af0d02a480b 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnStarter.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnStarter.ts @@ -116,8 +116,10 @@ export function startTurn(accessor: ServicesAccessor, request: ITurnStartRequest return undefined; } - telemetryReporter.userMessageSent(agent.id, request.clientId, request.clientContext, request.chat, request.turnId, state, request.source, request.message); + const isEphemeralSession = stateManager.isEphemeralSession(request.session); + const messageOriginKind = getMessageOriginTelemetryKind(request.message, isEphemeralSession); + telemetryReporter.userMessageSent(agent.id, request.clientId, request.clientContext, request.chat, request.turnId, state, request.source, request.message, isEphemeralSession); const { model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode } = getTurnTelemetryContext(agent, request.chat, createAgentChatContext(stateManager, request.session, request.chat), state, request.message.model?.id); - turnTracker.turnStarted(agent, request.chat, request.turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, request.clientContext, request.clientId, undefined, undefined, getMessageOriginTelemetryKind(request.message)); + turnTracker.turnStarted(agent, request.chat, request.turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, request.clientContext, request.clientId, undefined, undefined, messageOriginKind); return { agent }; } diff --git a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts index 81caac77519b06..aa28ae421a9e43 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts @@ -370,6 +370,10 @@ export class AgentHostTurnTracker extends Disposable { return this._turnTimings.get(this._key(session, turnId))?.clientContext; } + getMessageOriginKind(session: string, turnId: string): AgentHostMessageOriginTelemetryKind | undefined { + return this._turnTimings.get(this._key(session, turnId))?.messageOriginKind; + } + getInitiatorClientId(session: string, turnId: string): string | undefined { return this._turnTimings.get(this._key(session, turnId))?.initiatorClientId; } @@ -424,6 +428,7 @@ export class AgentHostTurnTracker extends Disposable { provider: timing.agent.id, session: timing.session, turnId, + messageOriginKind: timing.messageOriginKind, hangReason: timing.lastHangReason, result, hangReportCount: timing.hangReportCount, @@ -505,6 +510,7 @@ export class AgentHostTurnTracker extends Disposable { provider: timing.agent.id, session: timing.session, turnId: timing.turnId, + messageOriginKind: timing.messageOriginKind, hangReason, hadAnyProgress: timing.lastActivityKind !== TURN_ACTIVITY_NONE, lastActivityKind: timing.lastActivityKind, diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 969e653613441f..1882f0b4077456 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -72,7 +72,7 @@ import { IAgentHostSessionTitleController } from './agentHostSessionTitleControl import { AgentHostStateManager, resolveChatStateForUri } from './agentHostStateManager.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; import { createAgentChatContext, getSessionChatsForFanOut } from './agentChatContext.js'; -import { AgentHostTelemetryReporter, IAgentHostTelemetryReporter, type AgentHostTurnFailureStage, type AgentHostTurnResult, type IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; +import { AgentHostTelemetryReporter, getMessageOriginTelemetryKind, IAgentHostTelemetryReporter, type AgentHostMessageOriginTelemetryKind, type AgentHostTurnFailureStage, type AgentHostTurnResult, type IAgentHostTurnFailure } from './agentHostTelemetryReporter.js'; import { AgentHostToolCallTracker, IAgentHostToolCallTracker } from './agentHostToolCallTracker.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import { getConfiguredSessionMode, getModelTelemetryContext, getTurnTelemetryContext } from './agentHostTurnTelemetryContext.js'; @@ -131,6 +131,7 @@ interface ISubagentSessionRef { interface ISubagentParentTurnTelemetryContext { readonly parentTurnId: string | undefined; readonly parentClientContext: IAgentHostClientTelemetryContext | undefined; + readonly messageOriginKind: AgentHostMessageOriginTelemetryKind; /** Hierarchy edge; set only when the immediate parent chat has an active turn, else omitted. */ readonly correlatedParentTurnId: string | undefined; readonly initiatorClientId: string | undefined; @@ -987,7 +988,7 @@ export class AgentSideEffects extends Disposable { // supplied by the provider on the `subagent_started` signal. const turnId = generateUuid(); const parentTurnId = this._stateManager.getActiveTurnId(contentChatUri); - const { parentClientContext, correlatedParentTurnId, initiatorClientId } = this._getSubagentParentTurnTelemetryContext(immediateParentChatUri, contentChatUri); + const { parentClientContext, correlatedParentTurnId, initiatorClientId, messageOriginKind } = this._getSubagentParentTurnTelemetryContext(immediateParentChatUri, contentChatUri); this._stateManager.dispatchServerAction(subagentChatUri, { type: ActionType.ChatTurnStarted, turnId, @@ -997,7 +998,7 @@ export class AgentSideEffects extends Disposable { const agent = this._options.getAgent(parentSessionUri); if (agent) { const interactionMode = getConfiguredSessionMode(this._stateManager.getSessionState(parentSessionUri)?.config); - this._turnTracker.turnStarted(agent, subagentChatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, MessageKind.Tool); + this._turnTracker.turnStarted(agent, subagentChatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, messageOriginKind); this._turnTracker.setCurrentStage(subagentChatUri, turnId, 'provider'); } @@ -1062,7 +1063,7 @@ export class AgentSideEffects extends Disposable { const turnId = generateUuid(); const correlatedParentChatUri = immediateParentChatURI ?? subagent.immediateParentChatUri; const parentChatUri = correlatedParentChatUri ?? parentChatURI; - const { parentClientContext, correlatedParentTurnId, initiatorClientId } = this._getSubagentParentTurnTelemetryContext(correlatedParentChatUri, parentChatUri); + const { parentClientContext, correlatedParentTurnId, initiatorClientId, messageOriginKind } = this._getSubagentParentTurnTelemetryContext(correlatedParentChatUri, parentChatUri); this._logService.info(`[AgentSideEffects] Resuming subagent turn: ${subagent.chatUri} (parent=${parentChatURI}, toolCallId=${toolCallId})`); this._stateManager.dispatchServerAction(subagent.chatUri, { type: ActionType.ChatTurnStarted, @@ -1073,7 +1074,7 @@ export class AgentSideEffects extends Disposable { const agent = this._options.getAgent(subagent.sessionUri); if (agent) { const interactionMode = getConfiguredSessionMode(this._stateManager.getSessionState(subagent.sessionUri)?.config); - this._turnTracker.turnStarted(agent, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, MessageKind.Tool); + this._turnTracker.turnStarted(agent, subagent.chatUri, turnId, undefined, undefined, 'default', undefined, interactionMode, parentClientContext, initiatorClientId, correlatedParentTurnId, toolCallId, messageOriginKind); this._turnTracker.setCurrentStage(subagent.chatUri, turnId, 'provider'); } this._subagentChats.set({ ...subagent, immediateParentChatUri: correlatedParentChatUri, turnStopWatch: StopWatch.create(false) }, parentChatURI, toolCallId); @@ -1082,9 +1083,14 @@ export class AgentSideEffects extends Disposable { private _getSubagentParentTurnTelemetryContext(immediateParentChatUri: ProtocolURI | undefined, fallbackParentChatUri: ProtocolURI): ISubagentParentTurnTelemetryContext { const parentChatUri = immediateParentChatUri ?? fallbackParentChatUri; const parentTurnId = this._stateManager.getActiveTurnId(parentChatUri); + const parentSessionUri = parseRequiredSessionUriFromChatUri(parentChatUri); + const parentMessageOriginKind = parentTurnId ? this._turnTracker.getMessageOriginKind(parentChatUri, parentTurnId) : undefined; return { parentTurnId, parentClientContext: parentTurnId ? this._turnTracker.getClientTelemetryContext(parentChatUri, parentTurnId) : undefined, + messageOriginKind: parentMessageOriginKind === 'inline' || (!parentMessageOriginKind && this._stateManager.isEphemeralSession(parentSessionUri)) + ? 'inline' + : MessageKind.Tool, correlatedParentTurnId: immediateParentChatUri ? parentTurnId : undefined, initiatorClientId: parentTurnId ? this._turnTracker.getInitiatorClientId(parentChatUri, parentTurnId) : undefined, }; @@ -1382,7 +1388,7 @@ export class AgentSideEffects extends Disposable { } const state = this._stateManager.getSessionState(channel); const { model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode } = getTurnTelemetryContext(agent, channel, this._chatContext(sessionChannel, channel), state, resumedTurn.message.model?.id); - this._turnTracker.turnStarted(agent, channel, action.turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, clientContext, clientId); + this._turnTracker.turnStarted(agent, channel, action.turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, clientContext, clientId, undefined, undefined, getMessageOriginTelemetryKind(resumedTurn.message, this._stateManager.isEphemeralSession(sessionChannel))); this._turnTracker.setCurrentStage(channel, action.turnId, 'provider'); const key = this._resumedTurnExecutionKey(channel, action.turnId); const execution: IResumedTurnExecution = { diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts index c5a2297abd9aa1..0165df52ff0ad9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts @@ -84,7 +84,7 @@ suite('AgentHostTelemetryReporter', () => { const reporter = new AgentHostTelemetryReporter(service); const chat = buildSubagentChatUri(session, 'tool-call-1'); - reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), chat, 'turn-1', undefined, 'direct', userMessage); + reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), chat, 'turn-1', undefined, 'direct', userMessage, false); assert.deepStrictEqual(service.githubStandardEvents, [{ eventName: 'agentHost.userMessageSent', @@ -106,8 +106,8 @@ suite('AgentHostTelemetryReporter', () => { ...createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), machineId: 'client-machine-id', devDeviceId: 'client-dev-device-id', - }, session, 'turn-1', undefined, 'direct', userMessage); - reporter.userMessageSent('copilot', 'client-2', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), session, 'turn-2', undefined, 'direct', userMessage); + }, session, 'turn-1', undefined, 'direct', userMessage, false); + reporter.userMessageSent('copilot', 'client-2', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.EditorWindow), session, 'turn-2', undefined, 'direct', userMessage, false); assert.deepStrictEqual(service.standardEvents.map(event => ({ initiatorMachineId: event.data?.initiatorMachineId, @@ -128,17 +128,18 @@ suite('AgentHostTelemetryReporter', () => { const agentMergeMessage: Message = { text: 'fix the failing checks', origin: { kind: MessageKind.SystemNotification }, _meta: toAgentMergeMessageMeta() }; const spoofedMergeMessage: Message = { text: 'hello', origin: { kind: MessageKind.User }, _meta: toAgentMergeMessageMeta() }; - reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), session, 'turn-1', undefined, 'direct', agentMessage); - reporter.userMessageSent('copilot', undefined, createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), session, 'turn-2', undefined, 'direct', agentMergeMessage); - reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), session, 'turn-3', undefined, 'queued', userMessage); - reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), session, 'turn-4', undefined, 'direct', spoofedMergeMessage); + reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), session, 'turn-1', undefined, 'direct', agentMessage, false); + reporter.userMessageSent('copilot', undefined, createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown), session, 'turn-2', undefined, 'direct', agentMergeMessage, false); + reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), session, 'turn-3', undefined, 'queued', userMessage, false); + reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), session, 'turn-4', undefined, 'direct', spoofedMergeMessage, false); + reporter.userMessageSent('copilot', 'client-1', createUnknownAgentHostClientTelemetryContext(AgentHostClientType.AgentsWindow), session, 'turn-5', undefined, 'direct', userMessage, true); assert.deepStrictEqual({ standard: service.standardEvents.map(event => event.data?.messageOriginKind), github: service.githubStandardEvents.map(event => event.properties?.messageOriginKind), }, { - standard: ['agent', 'agentMerge', 'user', 'user'], - github: ['agent', 'agentMerge', 'user', 'user'], + standard: ['agent', 'agentMerge', 'user', 'user', 'inline'], + github: ['agent', 'agentMerge', 'user', 'user', 'inline'], }); }); @@ -425,6 +426,7 @@ suite('AgentHostTelemetryReporter', () => { provider: 'copilot', session, turnId: 'turn-1', + messageOriginKind: undefined, hangReason: 'stalledAfterProgress', hadAnyProgress: true, lastActivityKind: ActionType.ChatToolCallDelta, @@ -452,6 +454,7 @@ suite('AgentHostTelemetryReporter', () => { provider: 'copilot', session, turnId: 'turn-2', + messageOriginKind: undefined, hangReason: 'stalledAfterProgress', hadAnyProgress: true, lastActivityKind: 'custom/path/value', @@ -479,6 +482,7 @@ suite('AgentHostTelemetryReporter', () => { chatSessionId: getTelemetryChatSessionId(session), isSubagentSession: false, turnId: 'turn-1', + messageOriginKind: undefined, hangReason: 'stalledAfterProgress', isExpected: false, hadAnyProgress: true, @@ -507,6 +511,7 @@ suite('AgentHostTelemetryReporter', () => { chatSessionId: getTelemetryChatSessionId(session), isSubagentSession: false, turnId: 'turn-2', + messageOriginKind: undefined, hangReason: 'stalledAfterProgress', isExpected: false, hadAnyProgress: true, diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts index e135d5c73d9cbc..6d6ae3f25cf3ac 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts @@ -19,6 +19,7 @@ import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelatio import { AgentSession, IAgent } from '../../common/agent.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { createUnknownAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; +import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { SessionInputRequestKind } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; import { buildDefaultChatUri, buildSubagentChatUri, ChatInputQuestionKind, MessageKind, ResponsePartKind, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributorKind } from '../../common/state/sessionState.js'; @@ -115,7 +116,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { const sessionKey = sessionUri.toString(); const defaultChatUri = buildDefaultChatUri(sessionUri); - function setupSession(): void { + function setupSession(isEphemeral = false): void { stateManager.createSession({ resource: sessionKey, provider: 'mock', @@ -123,6 +124,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), + ...(isEphemeral ? { _meta: withEphemeralSessionMeta(undefined, true) } : {}), }); stateManager.dispatchServerAction(sessionKey, { type: ActionType.SessionReady }); } @@ -257,7 +259,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { test('reports noProgress for a turn that starts and is never heard from again', async () => { await runWithFakedTimers({}, async () => { - setupSession(); + setupSession(true); startTurn('turn-lost'); await timeout(TURN_HANG_THRESHOLD_MS); }); @@ -270,6 +272,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { chatSessionId: getTelemetryChatSessionId(defaultChatUri), isSubagentSession: false, turnId: 'turn-lost', + messageOriginKind: 'inline', hangReason: 'noProgress', isExpected: false, hadAnyProgress: false, @@ -418,7 +421,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { test('reports the paired recovery event when a hung turn later completes', async () => { await runWithFakedTimers({}, async () => { - setupSession(); + setupSession(true); startTurn('turn-recovered'); await timeout(TURN_HANG_THRESHOLD_MS); fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-recovered', duration: 1000 }); @@ -432,6 +435,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { chatSessionId: getTelemetryChatSessionId(defaultChatUri), isSubagentSession: false, turnId: 'turn-recovered', + messageOriginKind: 'inline', hangReason: 'noProgress', result: 'success', hangReportCount: 1, @@ -540,6 +544,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { chatSessionId: getTelemetryChatSessionId(session), isSubagentSession: false, turnId: 'turn', + messageOriginKind: undefined, hangReason: 'noProgress', isExpected: false, hadAnyProgress: false, diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 006a2871ef2412..82a141b84ff7a9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -22,6 +22,7 @@ import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportK import type { SessionMode } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType, type ChatAction, type ChatUsageAction } from '../../common/state/sessionActions.js'; +import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { toAgentMergeMessageMeta } from '../../common/meta/agentMergeMessageMeta.js'; import { buildDefaultChatUri, buildSubagentChatUri, createErrorResponsePart, type Message, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus } from '../../common/state/sessionState.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; @@ -118,7 +119,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { const sessionKey = sessionUri.toString(); const defaultChatUri = buildDefaultChatUri(sessionUri); - function setupSession(ready = true, workingDirectories?: string[]): void { + function setupSession(ready = true, workingDirectories?: string[], isEphemeral = false): void { stateManager.createSession({ resource: sessionKey, provider: 'mock', @@ -127,6 +128,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), ...(workingDirectories ? { workingDirectories } : {}), + ...(isEphemeral ? { _meta: withEphemeralSessionMeta(undefined, true) } : {}), }); if (ready) { stateManager.dispatchServerAction(sessionKey, { type: ActionType.SessionReady }); @@ -267,7 +269,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { ensureNoDisposablesAreLeakedInTestSuite(); test('emits turnCompleted with timing and turn-start context on success', () => { - setupSession(); + setupSession(true, undefined, true); agent.setModels([{ provider: 'mock', id: 'gpt-5.5', name: 'GPT 5.5', supportsVision: false }]); setSessionConfig({ autoApprove: 'autopilot', mode: 'interactive' }); startTurn('turn-1', 'hello', 'gpt-5.5'); @@ -290,10 +292,12 @@ suite('AgentSideEffects — turn tracker telemetry', () => { assert.strictEqual(data.isSubagentSession, false); assert.strictEqual(data.isBYOK, false); assert.strictEqual(data.interactionMode, 'interactive'); + assert.strictEqual(data.messageOriginKind, 'inline'); assert.strictEqual(typeof data.totalTime, 'number'); assert.strictEqual(typeof data.timeToFirstProgress, 'number'); assert.strictEqual(data.isMultiRoot, false); assert.strictEqual(data.folderCount, 0); + assert.strictEqual((telemetry.events.find(event => event.eventName === 'agentHost.userMessageSent')?.data as Record).messageOriginKind, 'inline'); }); test('attributes completed and failed turns to the initiating client identity', () => { @@ -317,6 +321,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { initiatorConnectionKind: data.initiatorConnectionKind, initiatorTransportKind: data.initiatorTransportKind, hostLaunchKind: data.hostLaunchKind, + messageOriginKind: data.messageOriginKind, initiatorMachineId: data.initiatorMachineId, initiatorDevDeviceId: data.initiatorDevDeviceId, }; @@ -326,6 +331,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { initiatorConnectionKind: 'remote_extension_host', initiatorTransportKind: 'message_port', hostLaunchKind: 'vscode_main_process', + messageOriginKind: 'user', initiatorMachineId: 'client-machine-id', initiatorDevDeviceId: 'client-dev-device-id', }, { @@ -334,6 +340,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { initiatorConnectionKind: 'remote_extension_host', initiatorTransportKind: 'message_port', hostLaunchKind: 'vscode_main_process', + messageOriginKind: 'user', initiatorMachineId: 'client-machine-id', initiatorDevDeviceId: 'client-dev-device-id', }]); @@ -1009,7 +1016,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { }); test('emits result=error when a queued sendMessage rejects', async () => { - setupSession(); + setupSession(true, undefined, true); agent.sendMessage = async () => { throw new Error('boom'); }; const setAction: ChatAction = { @@ -1026,6 +1033,8 @@ suite('AgentSideEffects — turn tracker telemetry', () => { const events = completedEvents(); assert.strictEqual(events.length, 1); assert.strictEqual((events[0].data as Record).result, 'error'); + assert.strictEqual((events[0].data as Record).messageOriginKind, 'inline'); + assert.strictEqual((telemetry.events.find(event => event.eventName === 'agentHost.userMessageSent')?.data as Record).messageOriginKind, 'inline'); }); test('captures interactionMode for queued turns', () => { diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index 3c89971d46df9d..ad12e5ba70d38b 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -1123,9 +1123,12 @@ export class AgentHostE2EServerLease { const client = this._client; const cleanupErrors: Error[] = []; if (client) { + // A session left unrestored after a host restart is restored on subscribe. + const restoreTimeout = getAgentHostE2ETestTimeout(10_000, 30_000); + const disposeTimeout = getAgentHostE2ETestTimeout(30_000, 90_000); for (const session of createdSessions) { try { - const state = await fetchSessionWithChat(client, session); + const state = await fetchSessionWithChat(client, session, restoreTimeout); if (state.activeTurn) { const chat = buildDefaultChatUri(session); const turnId = state.activeTurn.id; @@ -1141,14 +1144,14 @@ export class AgentHostE2EServerLease { 10_000, ); } - const root = await client.call('subscribe', { channel: ROOT_STATE_URI }); + const root = await client.call('subscribe', { channel: ROOT_STATE_URI }, restoreTimeout); const terminals = (root.snapshot!.state as RootState).terminals ?? []; for (const terminal of terminals) { if (terminal.claim.kind === TerminalClaimKind.Session && terminal.claim.session === session) { - await client.call('disposeTerminal', { channel: terminal.resource }, getAgentHostE2ETestTimeout(30_000, 90_000)); + await client.call('disposeTerminal', { channel: terminal.resource }, disposeTimeout); } } - await client.call('disposeSession', { channel: session }, getAgentHostE2ETestTimeout(30_000, 90_000)); + await client.call('disposeSession', { channel: session }, disposeTimeout); } catch (error) { cleanupErrors.push(error instanceof Error ? error : new Error(String(error))); } diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts index aecccd0db49dbc..3ab533818d6238 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts @@ -46,7 +46,7 @@ import { buildUncommittedChangesetUri, } from '../../../../common/changesetUri.js'; import { createRealSession, dispatchTurn, driveChatTurnToCompletion, driveTurnToCompletion, initTestGitRepo, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; -import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; +import { getActionEnvelope, getAgentHostE2ETestTimeout, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; /** The subset of `ChangesetFile` these tests assert on. */ @@ -284,6 +284,9 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { return state; } + // Re-reading git state is slow on a contended CI agent. + const operationPollRetries = getAgentHostE2ETestTimeout(100, 300); + async function waitForOperation(channel: string, operationId: string): Promise { return retry(async () => { const operation = (await changesetState(channel)).operations?.find(operation => operation.id === operationId); @@ -291,7 +294,7 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { throw new Error(`Changeset ${channel} has not advertised idle operation ${operationId}`); } return operation; - }, 100, 100); + }, 100, operationPollRetries); } async function waitForOperationRemoved(channel: string, operationId: string): Promise { @@ -299,7 +302,7 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { if ((await changesetState(channel)).operations?.some(operation => operation.id === operationId)) { throw new Error(`Changeset ${channel} still advertises operation ${operationId}`); } - }, 100, 100); + }, 100, operationPollRetries); } async function invokeChangesetOperation(channel: string, operationId: string): Promise<{ @@ -1486,7 +1489,7 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void { const workspace = createGitWorkspace(`ahp-provider-session-changeset-${config.provider}-`); const sessionUri = await createSessionIn(workspace, 'provider-session-changeset'); const peerUri = buildChatUri(sessionUri, generateUuid()); - await context.client.call('createChat', { channel: sessionUri, chat: peerUri, title: 'Changes Peer' }); + await context.client.call('createChat', { channel: sessionUri, chat: peerUri, title: 'Changes Peer' }, 30_000); await context.client.call('subscribe', { channel: peerUri }); const sessionChangeset = buildSessionChangesetUri(sessionUri); await context.client.call('subscribe', { channel: sessionChangeset }); diff --git a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts index 4355b3dbe4c6e5..44a2fc6bb5283f 100644 --- a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts @@ -76,10 +76,18 @@ interface IPendingCall { reject: (err: Error) => void; } +/** + * Default bound for one protocol request or notification wait. Short locally + * so a wedged host fails fast; longer on CI, where the E2E entrypoints run in + * parallel on a shared agent and contention alone can push a call past 5s. + */ function getProtocolOperationTimeout(): number { if (AGENT_HOST_E2E_COVERAGE) { return 30_000; } + if (isCI) { + return 20_000; + } return isWindows ? 8_000 : 5_000; } @@ -1057,11 +1065,11 @@ export function dispatchTurnStarted(c: TestProtocolClient, session: string, turn * requests) live on the session's default chat channel, so reading them * requires merging the session snapshot with its default chat snapshot. */ -export async function fetchSessionWithChat(c: TestProtocolClient, sessionUri: string): Promise { +export async function fetchSessionWithChat(c: TestProtocolClient, sessionUri: string, timeoutMs?: number): Promise { const owningSession = parseDefaultChatUri(sessionUri) ?? sessionUri; const chatUri = parseDefaultChatUri(sessionUri) ? sessionUri : buildDefaultChatUri(sessionUri); - const sessionSnap = await c.call('subscribe', { channel: owningSession }); - const chatSnap = await c.call('subscribe', { channel: chatUri }); + const sessionSnap = await c.call('subscribe', { channel: owningSession }, timeoutMs); + const chatSnap = await c.call('subscribe', { channel: chatUri }, timeoutMs); return mergeSessionWithDefaultChat( sessionSnap.snapshot!.state as SessionState, chatSnap.snapshot?.state as ChatState | undefined, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts index be59092f2f6c1c..ccca278dd48c2b 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts @@ -181,8 +181,8 @@ class WebContentsViewRendererFeature extends BrowserEditorContribution { store.add(model.onDidChangeVisibility(() => void this._doScreenshot())); store.add(model.onDidKeyCommand(keyEvent => void this._handleKeyEvent(keyEvent))); - store.add(model.onDidNavigate(() => this._refresh())); - store.add(model.onDidChangeLoadingState(() => this._refresh())); + store.add(model.onDidNavigate(() => this._refresh(true))); + store.add(model.onDidChangeLoadingState(() => this._refresh(true))); this._refresh(); void this._doScreenshot(); @@ -217,7 +217,7 @@ class WebContentsViewRendererFeature extends BrowserEditorContribution { * Recompute visibility of our content layers and the underlying page based * on the latest editor/overlay/model state. */ - private _refresh(): void { + private _refresh(restartScreenshot = false): void { // Placeholder screenshot: shown whenever there's a page to render // (covered by the WCV when it's up, visible during hide/show swaps). const placeholderActive = !!this._model?.url && !this._model?.error; @@ -232,6 +232,9 @@ class WebContentsViewRendererFeature extends BrowserEditorContribution { } const show = this._shouldShowPage(); if (show === this._model.visible) { + if (show && restartScreenshot) { + void this._doScreenshot(); + } return; } if (show) { @@ -268,11 +271,8 @@ class WebContentsViewRendererFeature extends BrowserEditorContribution { } private async _doScreenshot(): Promise { - if (!this._model) { - return; - } this._screenshotHandle.clear(); - if (!this._model.visible) { + if (!this._model?.url || this._model.error || !this._model.visible) { return; } try { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index b01895e4168515..b826c4b32e68cf 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -49,7 +49,7 @@ import { ICustomizationHarnessService } from '../../common/customizationHarnessS import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IAICustomizationListItem } from './aiCustomizationItemSource.js'; import { IAICustomizationItemsModel, ItemsModelSection } from './aiCustomizationItemsModel.js'; -import { createCustomizationCardPrimaryAction, CustomizationCardListController } from './customizationCardList.js'; +import { createCustomizationCardPrimaryAction, CustomizationCardListController, layoutVirtualizedSectionList, layoutVirtualizedSections, renderVirtualizedSectionLoadingPlaceholder, setupCollapsibleSection } from './customizationCardList.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; @@ -102,6 +102,13 @@ interface IFileItemEntry { type IListEntry = IGroupHeaderEntry | IFileItemEntry; +interface ICustomizationSectionList { + readonly list: WorkbenchList; + readonly items: readonly IAICustomizationListItem[]; + readonly container: HTMLElement; + readonly key: string; +} + /** * Delegate for the AI Customization list. */ @@ -245,6 +252,7 @@ class AICustomizationItemRenderer implements IListRenderer void, @IHoverService private readonly hoverService: IHoverService, @ILabelService private readonly labelService: ILabelService, @IMenuService private readonly menuService: IMenuService, @@ -357,7 +365,7 @@ class AICustomizationItemRenderer implements IListRenderer { - const actions = menu.getActions({ arg: context, shouldForwardArgs: true }); - const { primary } = getContextMenuActions(actions, 'inline'); templateData.actionBar.clear(); - templateData.actionBar.push(primary, { icon: true, label: false }); + if (element.promptType === PromptsType.agent || element.promptType === PromptsType.skill || element.promptType === PromptsType.instructions) { + const moreAction = templateData.elementDisposables.add(new Action( + 'aiCustomization.moreActions', + localize('customizationMoreActionsAria', "More actions for {0}", displayName), + ThemeIcon.asClassName(Codicon.ellipsis), + true, + () => this.showItemActions(element, templateData.actionsContainer), + )); + templateData.actionBar.push(moreAction, { icon: true, label: false }); + } else { + const actions = menu.getActions({ arg: context, shouldForwardArgs: true }); + const { primary } = getContextMenuActions(actions, 'inline'); + templateData.actionBar.push(primary, { icon: true, label: false }); + } }; updateActions(); templateData.elementDisposables.add(menu.onDidChange(updateActions)); @@ -600,6 +621,29 @@ interface ICustomizationItemGroup { /** * Widget that displays a searchable list of AI customization items. */ +export function getCollapsedCustomizationGroupKey(section: AICustomizationManagementSection, groupKey: string): string { + return `${section}:${groupKey}`; +} + +export function getCustomizationItemStatusLabel(item: IAICustomizationListItem): string | undefined { + switch (item.status) { + case 'loading': return localize('customizationStatusLoading', "Loading"); + case 'loaded': return localize('customizationStatusLoaded', "Loaded"); + case 'degraded': return localize('customizationStatusDegraded', "Needs attention"); + case 'error': return localize('customizationStatusError', "Error"); + default: return undefined; + } +} + +export function getCustomizationItemAriaLabel(item: IAICustomizationListItem): string { + const displayName = item.displayName ?? formatDisplayName(item.name); + const secondaryText = getCustomizationSecondaryText(item.description, item.filename, item.promptType); + const statusLabel = getCustomizationItemStatusLabel(item); + const accessibleSecondaryText = [secondaryText, statusLabel].filter(Boolean).join('. '); + const nameAndDescription = accessibleSecondaryText ? localize('itemAriaLabel', "{0}. {1}", displayName, accessibleSecondaryText) : displayName; + return item.disabled ? localize('itemAriaLabelDisabled', "{0}, disabled", nameAndDescription) : nameAndDescription; +} + export class AICustomizationListWidget extends Disposable { readonly element: HTMLElement; @@ -620,6 +664,8 @@ export class AICustomizationListWidget extends Disposable { private cardContainer!: HTMLElement; private cardScrollable!: DomScrollableElement; private cardScrollableNode!: HTMLElement; + private cardSectionLayoutContainer: HTMLElement | undefined; + private cardSectionLists: ICustomizationSectionList[] = []; private firstCardFocusElement: HTMLElement | undefined; private readonly cardRowsByUri = new Map(); private readonly cardRowsById = new Map(); @@ -634,13 +680,17 @@ export class AICustomizationListWidget extends Disposable { private allItems: readonly IAICustomizationListItem[] = []; private displayEntries: IListEntry[] = []; private searchQuery: string = ''; + private sectionLoading = false; private readonly collapsedGroups = new Set(); private _layoutDeferred = false; + private readonly revealLastItemScheduler = this._register(new MutableDisposable()); private lastLayoutWidth = 0; private lastLayoutHeight = 0; private lastHeaderHeight = 0; private readonly dropdownActionDisposables = this._register(new DisposableStore()); private readonly cardDisposables = this._register(new DisposableStore()); + private readonly pendingCardSectionLayout = this._register(new MutableDisposable()); + private readonly cardSectionScrollPositions = new Map(); /** Monotonically increasing counter; guards the post-load announcement against stale calls. */ private _sectionLoadId = 0; @@ -799,7 +849,7 @@ export class AICustomizationListWidget extends Disposable { this.element.appendChild(this.cardScrollableNode); const cardResizeObserver = this._register(new DOM.DisposableResizeObserver( 'AICustomizationListWidget.cardScrollable', - () => this.cardScrollable.scanDomNode(), + () => this.scheduleCardSectionLayout(), )); this._register(cardResizeObserver.observe(this.cardScrollableNode)); @@ -814,7 +864,10 @@ export class AICustomizationListWidget extends Disposable { this.emptyStateContainer.style.display = 'none'; // Create list - const itemRenderer = this.instantiationService.createInstance(AICustomizationItemRenderer); + const itemRenderer = this.instantiationService.createInstance( + AICustomizationItemRenderer, + (item: IAICustomizationListItem, anchor: HTMLElement) => this.showCardItemActions(item, anchor), + ); this.list = this._register(this.instantiationService.createInstance( WorkbenchList, 'AICustomizationManagementList', @@ -1029,7 +1082,9 @@ export class AICustomizationListWidget extends Disposable { getActions: () => actions, onHide: () => { this.cardMenuOpen = false; - (this.cardMenuButtonsById.get(item.id) ?? this.cardRowsById.get(item.id) ?? this.firstCardFocusElement)?.focus(); + if (!this.focusCardSectionItem(item.id)) { + (this.cardMenuButtonsById.get(item.id) ?? this.cardRowsById.get(item.id) ?? this.firstCardFocusElement)?.focus(); + } disposables.dispose(); }, }); @@ -1076,6 +1131,7 @@ export class AICustomizationListWidget extends Disposable { const modelSection = toItemsModelSection(section); if (!modelSection) { + this.sectionLoading = false; this.currentSectionSubscription.clear(); this.allItems = []; const matchCount = this.filterItems(); @@ -1086,6 +1142,7 @@ export class AICustomizationListWidget extends Disposable { } const observable = this.itemsModel.getItems(modelSection); + this.sectionLoading = true; this.currentSectionSubscription.value = autorun(reader => { const items = observable.read(reader); this.allItems = items; @@ -1098,6 +1155,8 @@ export class AICustomizationListWidget extends Disposable { // setSection() call may have already taken over and will make its own // announcement once its own load resolves. if (loadId === this._sectionLoadId) { + this.sectionLoading = false; + this.filterItems(); this.announceItemCount(this.applySearchFilter(this.allItems).length); } } @@ -1499,7 +1558,7 @@ export class AICustomizationListWidget extends Disposable { continue; } - const collapsed = !this.usesCardLayout() && this.collapsedGroups.has(group.groupKey); + const collapsed = !this.usesCardLayout() && this.collapsedGroups.has(this.getCollapsedGroupKey(group.groupKey)); this.displayEntries.push({ type: 'group-header', @@ -1608,13 +1667,16 @@ export class AICustomizationListWidget extends Disposable { const usesTargetedCreateActions = this.usesTargetedCreateActions(); const createGroupKey = isFiltering || usesTargetedCreateActions ? undefined : this.getCreateActionGroupKey(); const alwaysVisibleGroupKeys = new Set(getAlwaysVisibleCustomizationGroupKeys(this.currentSection, isFiltering)); - const visibleGroups = groups.filter(group => group.items.length > 0 || alwaysVisibleGroupKeys.has(group.groupKey) || group.groupKey === createGroupKey); + const visibleGroups = groups.filter(group => group.items.length > 0 || alwaysVisibleGroupKeys.has(group.groupKey) || group.groupKey === createGroupKey || this.sectionLoading && !isFiltering); if (visibleGroups.length === 0) { + this.captureCardSectionScrollPositions(); this.cardDisposables.clear(); + this.cardSectionLists = []; this.cardRowsByUri.clear(); this.cardRowsById.clear(); this.cardMenuButtonsById.clear(); this.firstCardFocusElement = undefined; + this.cardSectionLayoutContainer = undefined; DOM.clearNode(this.cardContainer); this.cardScrollableNode.style.display = 'none'; this.updateEmptyState(); @@ -1627,18 +1689,28 @@ export class AICustomizationListWidget extends Disposable { } } + this.captureCardSectionScrollPositions(); this.cardDisposables.clear(); + this.cardSectionLists = []; this.cardRowsByUri.clear(); this.cardRowsById.clear(); this.cardMenuButtonsById.clear(); this.firstCardFocusElement = undefined; + this.cardSectionLayoutContainer = undefined; DOM.clearNode(this.cardContainer); this.listContainer.style.display = 'none'; this.emptyStateContainer.style.display = 'none'; this.cardScrollableNode.style.display = ''; - const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-card-scroll-content.customization-card-scroll')); + const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-card-scroll-content.customization-card-scroll.distributed-section-layout')); + this.cardSectionLayoutContainer = content; + const contentResizeObserver = this.cardDisposables.add(new DOM.DisposableResizeObserver( + 'AICustomizationListWidget.cardScrollContent', + () => this.scheduleCardSectionLayout(), + )); + this.cardDisposables.add(contentResizeObserver.observe(content)); for (const group of visibleGroups) { + const collapsedGroupKey = this.getCollapsedGroupKey(group.groupKey); const section = DOM.append(content, $('.plugin-card-section.customization-card-section')); const header = DOM.append(section, $('.plugin-card-section-header')); const text = DOM.append(header, $('.plugin-card-section-text')); @@ -1657,26 +1729,141 @@ export class AICustomizationListWidget extends Disposable { this.renderCardCreateActions(header); } - const inventory = DOM.append(section, $('.plugin-card-grid.plugin-inventory-list.customization-inventory-list')); - const cardList = this.cardDisposables.add(new CustomizationCardListController(inventory, group.label)); + const inventory = DOM.append(section, $('.plugin-card-grid.plugin-inventory-list.customization-inventory-list.virtualized-section-list')); + setupCollapsibleSection( + headingRow, + inventory, + group.label, + this.cardDisposables, + this.collapsedGroups.has(collapsedGroupKey), + collapsed => { + if (collapsed) { + this.collapsedGroups.add(collapsedGroupKey); + } else { + this.collapsedGroups.delete(collapsedGroupKey); + } + this.layoutCardSectionLists(); + this.cardScrollable.scanDomNode(); + this.scheduleCardSectionLayout(); + }, + ); if (group.items.length === 0) { - const empty = DOM.append(inventory, $('.plugin-inventory-empty')); - empty.textContent = this.getEmptyGroupMessage(group.groupKey); + if (this.sectionLoading) { + renderVirtualizedSectionLoadingPlaceholder(inventory, localize('loadingCustomizations', "Loading customizations..."), ITEM_HEIGHT); + } else { + const empty = DOM.append(inventory, $('.plugin-inventory-empty')); + empty.textContent = this.getEmptyGroupMessage(group.groupKey); + } continue; } - for (const item of group.items) { - this.appendCustomizationCardRow(inventory, item, group.label, cardList); - } - cardList.finalize(); + this.createCustomizationSectionList(inventory, group.groupKey, group.label, group.items); } + this.layoutCardSectionLists(); this.cardScrollable.scanDomNode(); + this.scheduleCardSectionLayout(); if (shouldRestoreFocus) { DOM.getWindow(this.element).requestAnimationFrame(() => { - (this.cardMenuButtonsById.get(focusItemId ?? '') ?? this.cardRowsById.get(focusItemId ?? '') ?? this.firstCardFocusElement)?.focus(); + if (!focusItemId || !this.focusCardSectionItem(focusItemId)) { + this.firstCardFocusElement?.focus(); + } }); } } + private createCustomizationSectionList(container: HTMLElement, groupKey: string, label: string, items: readonly IAICustomizationListItem[]): void { + const key = `${this.currentSection}:${groupKey}`; + container.style.height = `${ITEM_HEIGHT}px`; + const itemRenderer = this.instantiationService.createInstance( + AICustomizationItemRenderer, + (item: IAICustomizationListItem, anchor: HTMLElement) => this.showCardItemActions(item, anchor), + ); + const list = this.cardDisposables.add(this.instantiationService.createInstance( + WorkbenchList, + `AICustomizationManagementList.${label}`, + container, + new AICustomizationListDelegate(), + [itemRenderer], + { + identityProvider: { getId: entry => entry.item.id }, + accessibilityProvider: { + getAriaLabel: entry => getCustomizationItemAriaLabel(entry.item), + getWidgetAriaLabel: () => label, + getSetSize: (_entry, _index, listLength) => listLength, + getPosInSet: (_entry, index) => index + 1, + }, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: entry => entry.item.name, + }, + multipleSelectionSupport: false, + openOnSingleClick: true, + }, + )); + list.splice(0, 0, items.map(item => ({ type: 'file-item', item }))); + list.scrollTop = this.cardSectionScrollPositions.get(key) ?? 0; + this.cardDisposables.add(list.onDidOpen(event => { + if (event.element) { + this._onDidSelectItem.fire(event.element.item); + } + })); + this.cardDisposables.add(list.onDidChangeFocus(event => { + itemRenderer.setFocusedIndex(event.indexes.length ? event.indexes[0] : -1); + if (event.elements.length > 0) { + this.lastCardFocusItemId = event.elements[0].item.id; + } + })); + this.cardDisposables.add(list.onDidFocus(() => { + if (list.getFocus().length === 0 && items.length > 0) { + list.setFocus([0]); + } + })); + this.cardDisposables.add(list.onContextMenu(event => this.onContextMenu(event as IListContextMenuEvent))); + this.cardSectionLists.push({ list, items, container, key }); + } + + private captureCardSectionScrollPositions(): void { + for (const section of this.cardSectionLists) { + this.cardSectionScrollPositions.set(section.key, section.list.scrollTop); + } + } + + private layoutCardSectionLists(): void { + const content = this.cardSectionLayoutContainer; + if (!content) { + return; + } + const heights = layoutVirtualizedSections(content, this.cardSectionLists.map(section => ({ + container: section.container, + contentHeight: section.items.length * ITEM_HEIGHT, + minimumHeight: ITEM_HEIGHT, + }))); + for (let index = 0; index < this.cardSectionLists.length; index++) { + const section = this.cardSectionLists[index]; + const height = heights[index]; + layoutVirtualizedSectionList(section.list, section.container, height, section.container.clientWidth || undefined); + } + } + + private scheduleCardSectionLayout(): void { + const targetWindow = DOM.getWindow(this.element); + this.pendingCardSectionLayout.value = DOM.scheduleAtNextAnimationFrame(targetWindow, () => { + this.layoutCardSectionLists(); + this.cardScrollable.scanDomNode(); + }); + } + + private focusCardSectionItem(itemId: string): boolean { + for (const section of this.cardSectionLists) { + const index = section.items.findIndex(item => item.id === itemId); + if (index >= 0) { + section.list.reveal(index); + section.list.setFocus([index]); + section.list.domFocus(); + return true; + } + } + return false; + } + private usesTargetedCreateActions(): boolean { return this.currentSection === AICustomizationManagementSection.Agents || this.currentSection === AICustomizationManagementSection.Skills @@ -1825,12 +2012,12 @@ export class AICustomizationListWidget extends Disposable { this.cardDisposables.add(button.onDidClick(() => this.executePrimaryCreateAction())); } - private appendCustomizationCardRow(parent: HTMLElement, item: IAICustomizationListItem, groupLabel: string, cardList: CustomizationCardListController): void { + protected appendCustomizationCardRow(parent: HTMLElement, item: IAICustomizationListItem, groupLabel: string, cardList: CustomizationCardListController): void { const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.customization-home-row')); row.classList.toggle('disabled', item.disabled); const displayName = item.displayName ?? formatDisplayName(item.name); const secondaryText = getCustomizationSecondaryText(item.description, item.filename, item.promptType); - const statusLabel = this.getItemStatusLabel(item); + const statusLabel = getCustomizationItemStatusLabel(item); const accessibleSecondaryText = [secondaryText, statusLabel].filter(Boolean).join('. '); const accessibleLabel = item.disabled ? localize('customizationCardAriaLabelDisabled', "{0}. {1}. Disabled", displayName, accessibleSecondaryText || groupLabel) @@ -1904,16 +2091,6 @@ export class AICustomizationListWidget extends Disposable { }); } - private getItemStatusLabel(item: IAICustomizationListItem): string | undefined { - switch (item.status) { - case 'loading': return localize('customizationStatusLoading', "Loading"); - case 'loaded': return localize('customizationStatusLoaded', "Loaded"); - case 'degraded': return localize('customizationStatusDegraded', "Needs attention"); - case 'error': return localize('customizationStatusError', "Error"); - default: return undefined; - } - } - /** * Filters items based on the current search query and builds grouped display entries. */ @@ -1928,14 +2105,19 @@ export class AICustomizationListWidget extends Disposable { * Toggles the collapsed state of a group. */ private toggleGroup(entry: IGroupHeaderEntry): void { - if (this.collapsedGroups.has(entry.groupKey)) { - this.collapsedGroups.delete(entry.groupKey); + const collapsedGroupKey = this.getCollapsedGroupKey(entry.groupKey); + if (this.collapsedGroups.has(collapsedGroupKey)) { + this.collapsedGroups.delete(collapsedGroupKey); } else { - this.collapsedGroups.add(entry.groupKey); + this.collapsedGroups.add(collapsedGroupKey); } this.filterItems(); } + private getCollapsedGroupKey(groupKey: string): string { + return getCollapsedCustomizationGroupKey(this.currentSection, groupKey); + } + private updateEmptyState(): void { const hasItems = this.displayEntries.length > 0; if (!hasItems) { @@ -2017,7 +2199,15 @@ export class AICustomizationListWidget extends Disposable { */ focusList(): void { if (this.usesCardLayout()) { - this.firstCardFocusElement?.focus(); + if (!this.firstCardFocusElement) { + const firstSection = this.cardSectionLists[0]; + if (firstSection?.items.length) { + firstSection.list.setFocus([0]); + firstSection.list.domFocus(); + } + } else { + this.firstCardFocusElement.focus(); + } return; } this.list.domFocus(); @@ -2031,7 +2221,16 @@ export class AICustomizationListWidget extends Disposable { */ revealLastItem(): void { if (this.usesCardLayout()) { - this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); + const reveal = () => { + const section = this.cardSectionLists.at(-1); + if (section?.items.length) { + section.list.reveal(section.items.length - 1); + } + this.cardScrollable.scanDomNode(); + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); + }; + reveal(); + this.revealLastItemScheduler.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.element), reveal); return; } if (this.displayEntries.length > 0) { @@ -2044,11 +2243,13 @@ export class AICustomizationListWidget extends Disposable { */ revealAndSelectFirstItemByUri(uris: readonly URI[]): boolean { if (this.usesCardLayout()) { - for (const uri of uris) { - const row = this.cardRowsByUri.get(uri.toString()); - if (row) { - row.scrollIntoView({ block: 'nearest' }); - row.focus(); + for (const section of this.cardSectionLists) { + const index = section.items.findIndex(item => uris.some(uri => isEqual(item.uri, uri))); + if (index >= 0) { + section.list.reveal(index); + section.list.setFocus([index]); + section.list.setSelection([index]); + section.list.domFocus(); return true; } } @@ -2074,6 +2275,9 @@ export class AICustomizationListWidget extends Disposable { layout(height: number, width: number): void { this.lastLayoutHeight = height; this.lastLayoutWidth = width; + if (this.element.parentElement?.style.display === 'none') { + return; + } this.element.classList.toggle('narrow-layout', width < 500); this.element.classList.toggle('wide-layout', width >= 600); // Use the CSS-computed height within the padded parent. @@ -2105,7 +2309,9 @@ export class AICustomizationListWidget extends Disposable { this.cardScrollableNode.style.height = `${listHeight}px`; this.listContainer.style.height = `${listHeight}px`; if (this.usesCardLayout()) { + this.layoutCardSectionLists(); this.cardScrollable.scanDomNode(); + this.scheduleCardSectionLayout(); } else { this.list.layout(listHeight, width); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 4f31883ebe5fc9..7c0041161c5b8d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -11,7 +11,7 @@ import { RunOnceScheduler, timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; import { getErrorMessage, onUnexpectedError } from '../../../../../base/common/errors.js'; -import { DisposableStore, IReference, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, IReference, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Action } from '../../../../../base/common/actions.js'; import { Event } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; @@ -36,6 +36,7 @@ import { WorkbenchList } from '../../../../../platform/list/browser/listService. import { IListVirtualDelegate, IListRenderer } from '../../../../../base/browser/ui/list/list.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -99,6 +100,7 @@ import { IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; import { IExtension } from '../../../extensions/common/extensions.js'; import { EmbeddedMcpServerDetail, IMcpServerDetailInput } from './embeddedMcpServerDetail.js'; import { EmbeddedAgentPluginDetail } from './embeddedAgentPluginDetail.js'; +import { layoutVirtualizedSectionList, layoutVirtualizedSections, setupCollapsibleSection } from './customizationCardList.js'; import { EmbeddedExtensionToolsDetail } from './embeddedExtensionToolsDetail.js'; import { ICustomizationHarnessService, type ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; import { ChatConfiguration } from '../../common/constants.js'; @@ -278,6 +280,154 @@ class SectionItemRenderer implements IListRenderer; + readonly renderer: MigrationItemRenderer; + readonly container: HTMLElement; + readonly items: readonly MigratableConfiguration[]; + readonly key: string; +} + +interface IMigrationItemTemplateData { + readonly container: HTMLElement; + readonly checkbox: Checkbox; + readonly openButton: Button; + readonly nameLabel: HTMLElement; + readonly pathLabel: HTMLElement; + readonly moreButton: HTMLButtonElement; + readonly templateDisposables: DisposableStore; + readonly elementDisposables: DisposableStore; + currentIndex?: number; + currentElement?: MigratableConfiguration; +} + +class MigrationItemDelegate implements IListVirtualDelegate { + getHeight(): number { + return MIGRATION_ITEM_HEIGHT; + } + + getTemplateId(): string { + return 'migrationItem'; + } +} + +class MigrationItemRenderer implements IListRenderer { + readonly templateId = 'migrationItem'; + private readonly templates = new Set(); + + constructor( + private readonly isSelected: (customization: MigratableConfiguration) => boolean, + private readonly getRelativePath: (customization: MigratableConfiguration) => string, + private readonly onSelectionChange: (customization: MigratableConfiguration, selected: boolean) => void, + private readonly onOpen: (customization: MigratableConfiguration) => void, + private readonly onMore: (customization: MigratableConfiguration, anchor: HTMLElement) => void, + private readonly onFirstFocusable: (element: HTMLElement) => void, + private readonly hoverService: IHoverService, + ) { } + + renderTemplate(container: HTMLElement): IMigrationItemTemplateData { + container.classList.add('ai-customization-list-item', 'prompt-migration-item'); + const templateDisposables = new DisposableStore(); + const elementDisposables = templateDisposables.add(new DisposableStore()); + + const checkboxContainer = DOM.append(container, $('.item-sync-checkbox.prompt-migration-checkbox')); + const checkbox = templateDisposables.add(new Checkbox('', false, defaultCheckboxStyles)); + checkboxContainer.replaceChildren(checkbox.domNode); + + const itemLeft = DOM.append(container, $('span.item-left')); + const openButton = templateDisposables.add(new Button(itemLeft, {})); + DOM.clearNode(openButton.element); + openButton.element.classList.add('item-text', 'prompt-migration-open-button'); + const nameRow = DOM.append(openButton.element, $('span.item-name-row')); + const nameLabel = DOM.append(nameRow, $('span.item-name.prompt-migration-item-name')); + const pathLabel = DOM.append(openButton.element, $('span.item-description.is-filename.prompt-migration-item-path')); + + const itemRight = DOM.append(container, $('span.item-right')); + const moreButton = DOM.append(itemRight, $('button.icon-button.prompt-migration-more-action', { type: 'button' })) as HTMLButtonElement; + moreButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.ellipsis)); + + const template: IMigrationItemTemplateData = { container, checkbox, openButton, nameLabel, pathLabel, moreButton, templateDisposables, elementDisposables }; + this.templates.add(template); + return template; + } + + renderElement(customization: MigratableConfiguration, index: number, templateData: IMigrationItemTemplateData): void { + templateData.elementDisposables.clear(); + templateData.container.removeAttribute('aria-selected'); + templateData.currentIndex = index; + templateData.currentElement = customization; + const displayName = customization.name ?? basename(customization.uri); + const relativePath = this.getRelativePath(customization); + const checkboxTitle = localize('customizationMigrationSelectAriaLabel', "Select {0}", displayName); + this.updateCheckboxState(templateData, customization); + templateData.checkbox.domNode.setAttribute('aria-label', checkboxTitle); + templateData.openButton.element.setAttribute('aria-label', localize('openCustomizationFile', "Open {0}, {1}", displayName, relativePath)); + templateData.nameLabel.textContent = displayName; + templateData.pathLabel.textContent = relativePath; + templateData.moreButton.setAttribute('aria-label', localize('customizationMigrationMoreActions', "More actions for {0}", displayName)); + if (index === 0) { + this.onFirstFocusable(templateData.checkbox.domNode); + } + + templateData.elementDisposables.add(templateData.checkbox.onChange(() => { + this.onSelectionChange(customization, templateData.checkbox.checked); + })); + templateData.elementDisposables.add(templateData.openButton.onDidClick(() => this.onOpen(customization))); + templateData.elementDisposables.add(this.hoverService.setupManagedHover( + getDefaultHoverDelegate('element'), + templateData.moreButton, + localize('moreActions', "More Actions"), + )); + templateData.elementDisposables.add(DOM.addDisposableListener(templateData.moreButton, 'click', event => { + event.stopPropagation(); + this.onMore(customization, templateData.moreButton); + })); + } + + refreshSelectionState(): void { + for (const template of this.templates) { + if (template.currentElement) { + this.updateCheckboxState(template, template.currentElement); + } + } + } + + private updateCheckboxState(templateData: IMigrationItemTemplateData, customization: MigratableConfiguration): void { + const selected = this.isSelected(customization); + templateData.checkbox.checked = selected; + templateData.checkbox.domNode.setAttribute('aria-checked', String(selected)); + } + + getIndex(target: HTMLElement): number | undefined { + for (const template of this.templates) { + if (template.container.contains(target)) { + return template.currentIndex; + } + } + return undefined; + } + + getControls(index: number): HTMLElement[] { + for (const template of this.templates) { + if (template.currentIndex === index) { + return [template.checkbox.domNode, template.openButton.element, template.moreButton]; + } + } + return []; + } + + disposeTemplate(templateData: IMigrationItemTemplateData): void { + this.templates.delete(templateData); + templateData.templateDisposables.dispose(); + } +} + +//#endregion + /** * Editor pane for the AI Customizations Management Editor. * Provides a global view of all AI customizations with a sidebar for navigation @@ -352,6 +502,9 @@ export class AICustomizationManagementEditor extends EditorPane { private migrationLinkElement: HTMLAnchorElement | undefined; private migrationSelectedCountElement: HTMLElement | undefined; private migrationFirstFocusableElement: HTMLElement | undefined; + private migrationSectionLists: IMigrationSectionList[] = []; + private collapsedMigrationSections: Set | undefined = new Set(); + private readonly migrationSectionScrollPositions = new Map(); private activeMigrationCategoryId: CustomizationMigrationCategoryId | undefined; private selectedCustomizationMigrationItems = new ResourceMap>(); private readonly migrationPageDisposables = this._register(new DisposableStore()); @@ -391,6 +544,7 @@ export class AICustomizationManagementEditor extends EditorPane { private customizationMigrationWritesInProgress = false; private readonly editorDisposables = this._register(new DisposableStore()); + private readonly pendingMigrationLayout = this._register(new MutableDisposable()); private _editorContentChanged = false; private _previousActiveHarnessId: string | undefined; @@ -489,6 +643,7 @@ export class AICustomizationManagementEditor extends EditorPane { } protected override createEditor(parent: HTMLElement): void { + this.pendingMigrationLayout.clear(); this.editorDisposables.clear(); this.contributedSectionContainers.clear(); this.contributedSectionWidgets.clear(); @@ -537,10 +692,18 @@ export class AICustomizationManagementEditor extends EditorPane { layout: (width, _, height) => { this.contentContainer.style.width = `${width}px`; if (height !== undefined) { - this.listWidget.layout(height - 16, width - 24); - this.mcpListWidget?.layout(height - 16, width - 24); - this.pluginListWidget?.layout(height - 16, width - 24); - this.toolsListWidget?.layout(height - 16, width - 24); + if (this.promptsContentContainer?.style.display !== 'none') { + this.listWidget.layout(height - 16, width - 24); + } + if (this.mcpContentContainer?.style.display !== 'none') { + this.mcpListWidget?.layout(height - 16, width - 24); + } + if (this.pluginContentContainer?.style.display !== 'none') { + this.pluginListWidget?.layout(height - 16, width - 24); + } + if (this.toolsContentContainer?.style.display !== 'none') { + this.toolsListWidget?.layout(height - 16, width - 24); + } const modelsFooterHeight = this.modelsFooterElement?.offsetHeight || 80; this.modelsWidget?.layout(height - 16 - modelsFooterHeight, width); if (this.viewMode === 'editor' && this.embeddedEditor && this.embeddedEditorContainer) { @@ -894,7 +1057,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.migrationBannerContainer = DOM.append(this.migrationContentContainer, $('.customization-migration-banner')); this.migrationBannerContainer.style.display = 'none'; - this.migrationListContainer = $('.prompt-migration-list.list-container'); + this.migrationListContainer = $('.prompt-migration-list.list-container.distributed-section-layout'); this.migrationListScrollable = this.editorDisposables.add(new DomScrollableElement(this.migrationListContainer, { horizontal: ScrollbarVisibility.Hidden, vertical: ScrollbarVisibility.Auto, @@ -903,13 +1066,6 @@ export class AICustomizationManagementEditor extends EditorPane { const migrationListScrollableNode = this.migrationListScrollable.getDomNode(); migrationListScrollableNode.classList.add('prompt-migration-list-scrollable'); this.migrationContentContainer.appendChild(migrationListScrollableNode); - const targetWindow = DOM.getWindow(this.migrationContentContainer); - const migrationResizeObserver = this.editorDisposables.add(new DOM.DisposableResizeObserver( - 'AICustomizationManagementEditor.promptMigrationListScrollable', - () => this.migrationListScrollable?.scanDomNode(), - targetWindow, - )); - this.editorDisposables.add(migrationResizeObserver.observe(migrationListScrollableNode)); const footer = DOM.append(this.migrationContentContainer, $('.prompt-migration-footer')); this.migrationSelectedCountElement = DOM.append(footer, $('span.prompt-migration-selected-count')); @@ -927,7 +1083,17 @@ export class AICustomizationManagementEditor extends EditorPane { .filter(customization => this.isCustomizationSelectedForMigration(customization)); void this.migrateSelectedCustomizations(category, selectedCustomizations); })); + const targetWindow = DOM.getWindow(this.migrationContentContainer); + const migrationResizeObserver = this.editorDisposables.add(new DOM.DisposableResizeObserver( + 'AICustomizationManagementEditor.promptMigrationListScrollable', + () => this.scheduleMigrationSectionLayout(), + targetWindow, + )); + this.editorDisposables.add(migrationResizeObserver.observe(migrationListScrollableNode)); this.renderCustomizationMigrationPage(); + if (this.viewMode === 'migration') { + this.scheduleMigrationSectionLayout(); + } } private createContent(): void { @@ -1391,9 +1557,13 @@ export class AICustomizationManagementEditor extends EditorPane { return; } + for (const section of this.migrationSectionLists) { + this.migrationSectionScrollPositions?.set(section.key, section.list.scrollTop); + } this.migrationPageDisposables.clear(); DOM.clearNode(this.migrationListContainer); this.migrationFirstFocusableElement = undefined; + this.migrationSectionLists = []; const category = this.getActiveMigrationCategory() ?? CUSTOMIZATION_MIGRATION_CATEGORIES[0]; const candidates = this.getMigrationCandidates(category); @@ -1426,78 +1596,6 @@ export class AICustomizationManagementEditor extends EditorPane { return; } - const openCustomizationInEmbeddedEditor = (customization: MigratableConfiguration): void => { - const isWorkspaceFile = customization.storage === PromptsStorage.local; - void this.showEmbeddedEditor( - customization.uri, - customization.name ?? basename(customization.uri), - customization.type, - customization.storage, - isWorkspaceFile, - ); - }; - const renderSelectionCheckbox = (row: HTMLElement, customization: MigratableConfiguration, onSelectionChange?: () => void): Checkbox => { - const checkboxContainer = DOM.append(row, $('.item-sync-checkbox.prompt-migration-checkbox')); - const checkboxTitle = localize('customizationMigrationSelectAriaLabel', "Select {0}", customization.name ?? basename(customization.uri)); - const checkbox = this.migrationPageDisposables.add(new Checkbox(checkboxTitle, this.isCustomizationSelectedForMigration(customization), defaultCheckboxStyles)); - checkboxContainer.replaceChildren(checkbox.domNode); - this.migrationFirstFocusableElement ??= checkbox.domNode; - this.migrationPageDisposables.add(checkbox.onChange(() => { - this.setCustomizationSelectedForMigration(customization, checkbox.checked); - this.updateCustomizationMigrationActionState(); - onSelectionChange?.(); - })); - return checkbox; - }; - - const renderItem = (container: HTMLElement, customization: MigratableConfiguration, onSelectionChange?: () => void): Checkbox => { - const row = DOM.append(container, $('div.ai-customization-list-item.prompt-migration-item')); - const checkbox = renderSelectionCheckbox(row, customization, onSelectionChange); - - const itemLeft = DOM.append(row, $('span.item-left')); - const displayName = customization.name ?? basename(customization.uri); - const relativePath = this.labelService.getUriLabel(customization.uri, { relative: true }); - const openButton = this.migrationPageDisposables.add(new Button(itemLeft, { - ariaLabel: localize('openCustomizationFile', "Open {0}, {1}", displayName, relativePath), - })); - openButton.label = displayName; - DOM.clearNode(openButton.element); - openButton.element.classList.add('item-text', 'prompt-migration-open-button'); - this.migrationPageDisposables.add(openButton.onDidClick(() => openCustomizationInEmbeddedEditor(customization))); - const itemText = openButton.element; - const nameRow = DOM.append(itemText, $('span.item-name-row')); - const nameLabel = DOM.append(nameRow, $('span.item-name.prompt-migration-item-name')); - nameLabel.textContent = displayName; - - const pathLabel = DOM.append(itemText, $('span.item-description.is-filename.prompt-migration-item-path')); - pathLabel.textContent = relativePath; - - const itemRight = DOM.append(row, $('span.item-right')); - const moreButton = DOM.append(itemRight, $('button.icon-button.prompt-migration-more-action', { - type: 'button', - 'aria-label': localize('customizationMigrationMoreActions', "More actions for {0}", customization.name ?? basename(customization.uri)), - })) as HTMLButtonElement; - moreButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.ellipsis)); - this.migrationPageDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), moreButton, localize('moreActions', "More Actions"))); - this.migrationPageDisposables.add(DOM.addDisposableListener(moreButton, 'click', event => { - event.stopPropagation(); - const actions = new DisposableStore(); - const deleteAction = actions.add(new Action( - 'customizationMigration.delete', - localize('delete', "Delete"), - ThemeIcon.asClassName(Codicon.trash), - true, - () => this.deleteCustomizationFile(customization), - )); - this.contextMenuService.showContextMenu({ - getAnchor: () => moreButton, - getActions: () => [deleteAction], - onHide: () => actions.dispose(), - }); - })); - return checkbox; - }; - const renderGroup = (groupKey: string, groupLabel: string, customizations: readonly MigratableConfiguration[]): void => { const group = DOM.append(this.migrationListContainer!, $('.prompt-migration-group')); const groupHeader = DOM.append(group, $('.prompt-migration-group-header')); @@ -1513,6 +1611,23 @@ export class AICustomizationManagementEditor extends EditorPane { "No customizations are available to migrate from {0}.", groupLabel, ); + const sectionKey = `${category.id}:${groupKey}`; + const collapsedSections = this.collapsedMigrationSections ??= new Set(); + setupCollapsibleSection( + groupHeading, + emptyItems, + groupLabel, + this.migrationPageDisposables, + collapsedSections.has(sectionKey), + collapsed => { + if (collapsed) { + collapsedSections.add(sectionKey); + } else { + collapsedSections.delete(sectionKey); + } + this.scheduleMigrationSectionLayout(); + }, + ); return; } const selectedInGroup = customizations.filter(customization => this.isCustomizationSelectedForMigration(customization)).length; @@ -1532,15 +1647,37 @@ export class AICustomizationManagementEditor extends EditorPane { groupCheckbox.domNode.setAttribute('aria-checked', String(state)); }; setGroupCheckboxState(initialGroupState); - const itemCheckboxes: Checkbox[] = []; + const updateGroupCheckboxState = (): void => { + const selectedCount = customizations.filter(customization => this.isCustomizationSelectedForMigration(customization)).length; + setGroupCheckboxState(selectedCount === customizations.length ? true : selectedCount === 0 ? false : 'mixed'); + }; + const groupId = `prompt-migration-group-${category.id}-${groupKey}`; + const groupItems = DOM.append(group, $('.prompt-migration-group-items.virtualized-section-list')); + groupItems.id = `${groupId}-items`; + const sectionKey = `${category.id}:${groupKey}`; + const collapsedSections = this.collapsedMigrationSections ??= new Set(); + setupCollapsibleSection( + groupHeading, + groupItems, + groupLabel, + this.migrationPageDisposables, + collapsedSections.has(sectionKey), + collapsed => { + if (collapsed) { + collapsedSections.add(sectionKey); + } else { + collapsedSections.delete(sectionKey); + } + this.scheduleMigrationSectionLayout(); + }, + ); + const section = this.createMigrationSectionList(groupItems, sectionKey, groupLabel, customizations, updateGroupCheckboxState); const setGroupSelection = (selected: boolean): void => { for (const customization of customizations) { this.setCustomizationSelectedForMigration(customization, selected); } - for (const itemCheckbox of itemCheckboxes) { - itemCheckbox.checked = selected; - } this.updateCustomizationMigrationActionState(); + section.renderer.refreshSelectionState(); }; this.migrationPageDisposables.add(groupCheckbox.onChange(() => setGroupSelection(groupCheckbox.checked === true))); this.migrationPageDisposables.add(DOM.addDisposableListener(selectAllLabel, 'click', e => { @@ -1550,17 +1687,6 @@ export class AICustomizationManagementEditor extends EditorPane { setGroupSelection(selected); groupCheckbox.focus(); })); - const updateGroupCheckboxState = (): void => { - const selectedCount = customizations.filter(customization => this.isCustomizationSelectedForMigration(customization)).length; - setGroupCheckboxState(selectedCount === customizations.length ? true : selectedCount === 0 ? false : 'mixed'); - }; - const groupId = `prompt-migration-group-${category.id}-${groupKey}`; - const groupItems = DOM.append(group, $('.prompt-migration-group-items')); - groupItems.id = `${groupId}-items`; - - for (const customization of customizations) { - itemCheckboxes.push(renderItem(groupItems, customization, updateGroupCheckboxState)); - } }; const groups = category.group(candidates); @@ -1572,12 +1698,161 @@ export class AICustomizationManagementEditor extends EditorPane { renderGroup(group.key, group.label, group.customizations); } - for (const customization of candidates.filter(item => !groupedUris.has(item.uri))) { - renderItem(this.migrationListContainer, customization); + const ungroupedCandidates = candidates.filter(item => !groupedUris.has(item.uri)); + if (ungroupedCandidates.length > 0) { + const ungroupedItems = DOM.append(this.migrationListContainer, $('.prompt-migration-group-items.virtualized-section-list')); + this.createMigrationSectionList(ungroupedItems, `${category.id}:ungrouped`, category.pageTitle, ungroupedCandidates); } this.updateCustomizationMigrationActionState(); - this.migrationListScrollable?.scanDomNode(); + this.scheduleMigrationSectionLayout(); + } + + private createMigrationSectionList( + container: HTMLElement, + key: string, + label: string, + items: readonly MigratableConfiguration[], + onSelectionChange?: () => void, + ): IMigrationSectionList { + container.style.height = `${MIGRATION_ITEM_HEIGHT}px`; + const renderer = new MigrationItemRenderer( + customization => this.isCustomizationSelectedForMigration(customization), + customization => this.labelService.getUriLabel(customization.uri, { relative: true }), + (customization, selected) => { + this.setCustomizationSelectedForMigration(customization, selected); + this.updateCustomizationMigrationActionState(); + onSelectionChange?.(); + }, + customization => { + const isWorkspaceFile = customization.storage === PromptsStorage.local; + void this.showEmbeddedEditor( + customization.uri, + customization.name ?? basename(customization.uri), + customization.type, + customization.storage, + isWorkspaceFile, + ); + }, + (customization, anchor) => this.showCustomizationMigrationItemActions(customization, anchor), + element => this.migrationFirstFocusableElement ??= element, + this.hoverService, + ); + const list = this.migrationPageDisposables.add(this.instantiationService.createInstance( + WorkbenchList, + `CustomizationMigration.${label}`, + container, + new MigrationItemDelegate(), + [renderer], + { + multipleSelectionSupport: false, + horizontalScrolling: false, + accessibilityProvider: { + getWidgetAriaLabel: () => label, + getAriaLabel: customization => localize( + 'customizationMigrationItemAriaLabel', + "{0}, {1}", + customization.name ?? basename(customization.uri), + this.labelService.getUriLabel(customization.uri, { relative: true }), + ), + getSetSize: (_element, _index, listLength) => listLength, + getPosInSet: (_element, index) => index + 1, + }, + identityProvider: { + getId: customization => `${customization.storage}:${customization.uri.toString()}`, + }, + }, + )); + list.splice(0, 0, items); + list.scrollTop = this.migrationSectionScrollPositions?.get(key) ?? 0; + this.migrationPageDisposables.add(list.onDidChangeSelection(event => { + if (event.indexes.length > 0) { + list.setSelection([]); + } + })); + this.migrationPageDisposables.add(DOM.addStandardDisposableListener(container, DOM.EventType.KEY_DOWN, event => { + if (event.keyCode !== KeyCode.Tab) { + return; + } + const target = event.target; + if (!DOM.isHTMLElement(target)) { + return; + } + const index = renderer.getIndex(target); + if (index === undefined) { + return; + } + const controls = renderer.getControls(index); + const controlIndex = controls.findIndex(control => control === target || control.contains(target)); + const targetIndex = event.shiftKey ? index - 1 : index + 1; + const crossesRowBoundary = event.shiftKey ? controlIndex === 0 : controlIndex === controls.length - 1; + if (!crossesRowBoundary || targetIndex < 0 || targetIndex >= items.length) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + list.setFocus([targetIndex]); + list.reveal(targetIndex); + const targetControls = renderer.getControls(targetIndex); + targetControls[event.shiftKey ? targetControls.length - 1 : 0]?.focus(); + })); + const section = { list, renderer, container, items, key }; + this.migrationSectionLists.push(section); + return section; + } + + private showCustomizationMigrationItemActions(customization: MigratableConfiguration, anchor: HTMLElement): void { + const actions = new DisposableStore(); + const deleteAction = actions.add(new Action( + 'customizationMigration.delete', + localize('delete', "Delete"), + ThemeIcon.asClassName(Codicon.trash), + true, + () => this.deleteCustomizationFile(customization), + )); + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => [deleteAction], + onHide: () => actions.dispose(), + }); + } + + private layoutMigrationSectionLists(): void { + if (!this.migrationListContainer) { + return; + } + const heights = layoutVirtualizedSections(this.migrationListContainer, this.migrationSectionLists.map(section => ({ + container: section.container, + contentHeight: section.items.length * MIGRATION_ITEM_HEIGHT, + minimumHeight: MIGRATION_ITEM_HEIGHT, + }))); + for (let index = 0; index < this.migrationSectionLists.length; index++) { + const section = this.migrationSectionLists[index]; + const height = heights[index]; + layoutVirtualizedSectionList(section.list, section.container, height, section.container.clientWidth || undefined); + } + } + + private scheduleMigrationSectionLayout(): void { + if (this.migrationContentContainer?.style.display === 'none') { + this.pendingMigrationLayout?.clear(); + return; + } + if (!this.pendingMigrationLayout) { + this.layoutMigrationSectionLists(); + this.migrationListScrollable?.scanDomNode(); + return; + } + + if (!this.migrationListContainer) { + this.pendingMigrationLayout.clear(); + return; + } + this.pendingMigrationLayout.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.migrationListContainer), () => { + this.layoutMigrationSectionLists(); + this.migrationListScrollable?.scanDomNode(); + }); } private renderCustomizationMigrationState(title: string, description: string, retry?: () => void): void { @@ -2342,7 +2617,9 @@ export class AICustomizationManagementEditor extends EditorPane { for (const widget of this.contributedSectionWidgets.values()) { widget.layout?.(dimension); } - this.migrationListScrollable?.scanDomNode(); + if (this.viewMode === 'migration') { + this.scheduleMigrationSectionLayout(); + } } override focus(): void { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCardList.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCardList.ts index 7cddeff5b590e4..2aa042851f1ba9 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCardList.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCardList.ts @@ -5,9 +5,13 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { disposableTimeout } from '../../../../../base/common/async.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { localize } from '../../../../../nls.js'; const $ = DOM.$; +let collapsibleSectionIdPool = 0; export interface ICustomizationCardListItem { readonly row: HTMLElement; @@ -30,6 +34,161 @@ export function createCustomizationCardPrimaryAction(parent: HTMLElement, ariaLa return button; } +export function setupCollapsibleSection( + headingRow: HTMLElement, + content: HTMLElement, + label: string, + disposables: DisposableStore, + initiallyCollapsed: boolean, + onDidChange: (collapsed: boolean) => void, +): HTMLButtonElement { + const toggle = $('.customization-section-toggle') as HTMLButtonElement; + toggle.type = 'button'; + headingRow.prepend(toggle); + content.id ||= `customization-section-content-${++collapsibleSectionIdPool}`; + toggle.setAttribute('aria-controls', content.id); + + let collapsed = initiallyCollapsed; + const expandedDisplay = content.style.display; + const update = () => { + toggle.className = 'customization-section-toggle'; + toggle.classList.add(...ThemeIcon.asClassName(collapsed ? Codicon.chevronRight : Codicon.chevronDown).split(' ')); + toggle.setAttribute('aria-expanded', String(!collapsed)); + toggle.setAttribute('aria-label', collapsed + ? localize('expandCustomizationSection', "Expand {0}", label) + : localize('collapseCustomizationSection', "Collapse {0}", label)); + content.hidden = collapsed; + content.style.display = collapsed ? 'none' : expandedDisplay; + }; + update(); + + disposables.add(DOM.addDisposableListener(toggle, DOM.EventType.CLICK, event => { + DOM.EventHelper.stop(event, true); + collapsed = !collapsed; + update(); + onDidChange(collapsed); + })); + return toggle; +} + +export interface IVirtualizedSectionLayout { + readonly container: HTMLElement; + readonly contentHeight: number; + readonly minimumHeight: number; +} + +export function renderVirtualizedSectionLoadingPlaceholder(container: HTMLElement, label: string, height: number): HTMLElement { + const placeholder = DOM.append(container, $('.virtualized-section-loading')); + placeholder.style.height = `${height}px`; + placeholder.textContent = label; + return placeholder; +} + +export interface IVirtualizedSectionList { + scrollTop: number; + layout(height: number, width?: number): void; +} + +export function layoutVirtualizedSectionList(list: IVirtualizedSectionList, container: HTMLElement, height: number, width?: number): void { + if (height === 0) { + container.style.height = '0px'; + return; + } + + const scrollTop = list.scrollTop; + container.style.height = `${height}px`; + list.layout(height, width); + list.scrollTop = scrollTop; +} + +export function setVirtualizedRowActionsTabbable(container: HTMLElement, tabbable: boolean): void { + const visit = (element: Element): void => { + if (DOM.isHTMLElement(element)) { + const role = element.getAttribute('role'); + const isAction = DOM.isHTMLButtonElement(element) + || DOM.isHTMLAnchorElement(element) && element.hasAttribute('href') + || role === 'button' + || role === 'switch' + || role === 'checkbox' + || role === 'menuitem'; + if (isAction) { + const disabled = DOM.isHTMLButtonElement(element) && element.disabled || element.getAttribute('aria-disabled') === 'true'; + element.tabIndex = tabbable && !disabled ? 0 : -1; + } + } + for (const child of element.children) { + visit(child); + } + }; + visit(container); +} + +export function layoutVirtualizedSections(root: HTMLElement, sections: readonly IVirtualizedSectionLayout[]): readonly number[] { + const visibleSections = sections.filter(section => !section.container.hidden); + const availableRootHeight = root.clientHeight; + if (availableRootHeight <= 0) { + root.classList.remove('virtualized-section-layout-overflow'); + root.style.overflow = ''; + return sections.map(section => section.container.hidden ? 0 : section.contentHeight); + } + + const targetWindow = DOM.getWindow(root); + const rootStyle = targetWindow.getComputedStyle(root); + let fixedHeight = (parseFloat(rootStyle.paddingTop) || 0) + (parseFloat(rootStyle.paddingBottom) || 0); + const children = Array.from(root.children) as HTMLElement[]; + const rowGap = parseFloat(rootStyle.rowGap) || 0; + fixedHeight += Math.max(0, children.length - 1) * rowGap; + + for (const child of children) { + const childStyle = targetWindow.getComputedStyle(child); + let childHeight = child.offsetHeight + (parseFloat(childStyle.marginTop) || 0) + (parseFloat(childStyle.marginBottom) || 0); + for (const section of visibleSections) { + if (child === section.container || child.contains(section.container)) { + childHeight -= section.container.clientHeight || section.container.offsetHeight; + } + } + fixedHeight += childHeight; + } + + const availableListHeightBeforeMinimums = availableRootHeight - fixedHeight; + const availableListHeight = Math.max(0, availableListHeightBeforeMinimums); + const allocations = new Map(); + const minimumAllocations = new Map(visibleSections.map(section => [ + section, + Math.min(section.contentHeight, section.minimumHeight), + ])); + const minimumListHeight = visibleSections.reduce((height, section) => height + minimumAllocations.get(section)!, 0); + const requiresPageScroll = minimumListHeight - availableListHeightBeforeMinimums > 1; + root.classList.toggle('virtualized-section-layout-overflow', requiresPageScroll); + root.style.overflow = requiresPageScroll ? 'visible' : ''; + if (availableListHeight <= minimumListHeight) { + return sections.map(section => section.container.hidden ? 0 : minimumAllocations.get(section) ?? 0); + } + + for (const section of visibleSections) { + allocations.set(section, minimumAllocations.get(section)!); + } + let remainingHeight = availableListHeight - minimumListHeight; + let remainingSections = visibleSections.filter(section => section.contentHeight > minimumAllocations.get(section)!); + while (remainingSections.length > 0) { + const equalShare = remainingHeight / remainingSections.length; + const completed = remainingSections.filter(section => section.contentHeight - allocations.get(section)! <= equalShare); + if (completed.length === 0) { + for (const section of remainingSections) { + allocations.set(section, allocations.get(section)! + equalShare); + } + break; + } + for (const section of completed) { + remainingHeight -= section.contentHeight - allocations.get(section)!; + allocations.set(section, section.contentHeight); + } + remainingSections = remainingSections.filter(section => !completed.includes(section)); + } + + return sections.map(section => section.container.hidden ? 0 : Math.max(0, Math.floor(allocations.get(section) ?? 0))); +} + export class CustomizationCardListController extends Disposable { private readonly items: ICardListItem[] = []; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index f5ca75547dc6e2..8f3d2853cbc627 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -5,11 +5,12 @@ import './media/aiCustomizationManagement.css'; import * as DOM from '../../../../../base/browser/dom.js'; +import { IMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { Disposable, DisposableStore, isDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Emitter } from '../../../../../base/common/event.js'; import { localize } from '../../../../../nls.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IListRenderer } from '../../../../../base/browser/ui/list/list.js'; +import { IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; @@ -25,7 +26,7 @@ import { MCP_PLUGIN_COLLECTION_ID_PREFIX } from '../../../mcp/common/discovery/p import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { ContributionEnablementState, isContributionDisabled, isContributionEnabled } from '../../common/enablement.js'; import { McpCommandIds } from '../../../../contrib/mcp/common/mcpCommandIds.js'; -import { autorun } from '../../../../../base/common/observable.js'; +import { autorun, derived, IObservable, observableSignalFromEvent } from '../../../../../base/common/observable.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { URI } from '../../../../../base/common/uri.js'; import { InputBox, MessageType } from '../../../../../base/browser/ui/inputbox/inputBox.js'; @@ -55,13 +56,15 @@ import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { IMcpServerConfiguration, McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { createWorkbenchMcpServerDetailInput, IMcpServerDetailInput } from './embeddedMcpServerDetail.js'; -import { createCustomizationCardPrimaryAction, CustomizationCardListController } from './customizationCardList.js'; +import { createCustomizationCardPrimaryAction, CustomizationCardListController, layoutVirtualizedSectionList, layoutVirtualizedSections, renderVirtualizedSectionLoadingPlaceholder, setVirtualizedRowActionsTabbable, setupCollapsibleSection } from './customizationCardList.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; +import { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; const $ = DOM.$; const PLUGIN_COLLECTION_PREFIX = MCP_PLUGIN_COLLECTION_ID_PREFIX; +const MCP_SECTION_ITEM_HEIGHT = 66; const COPILOT_EXTENSION_IDS = ['github.copilot', 'github.copilot-chat']; @@ -119,6 +122,30 @@ export function isMcpServerCollectionVisible(collectionId: string, hiddenCollect type IMcpInstalledEntry = IMcpServerItemEntry | IMcpSessionServerItemEntry | IMcpBuiltinItemEntry; +interface IMcpMarketplaceEntry { + readonly type: 'marketplace-item'; + readonly server: IWorkbenchMcpServer; +} + +type IMcpSectionEntry = IMcpInstalledEntry | IMcpMarketplaceEntry; + +interface IMcpSectionList { + readonly list: WorkbenchList; + readonly entries: readonly IMcpSectionEntry[]; + readonly container: HTMLElement; + readonly key: string; +} + +class McpSectionDelegate implements IListVirtualDelegate { + getHeight(): number { + return MCP_SECTION_ITEM_HEIGHT; + } + + getTemplateId(element: IMcpSectionEntry): string { + return element.type === 'marketplace-item' ? 'mcpMarketplaceItem' : 'mcpServerItem'; + } +} + interface IMcpInstalledPresentation { readonly entry: IMcpInstalledEntry; } @@ -142,6 +169,7 @@ interface IMcpServerItemTemplateData { readonly container: HTMLElement; readonly typeIcon: HTMLElement; readonly name: HTMLElement; + readonly statusBadge: HTMLElement; readonly description: HTMLElement; readonly actions: HTMLElement; readonly elementDisposables: DisposableStore; @@ -150,6 +178,7 @@ interface IMcpServerItemTemplateData { renderedRowKey?: string; /** What the actions currently show, so an unchanged status does not rebuild them. */ renderedStatusSignature?: string; + currentIndex: number; } /** @@ -163,9 +192,12 @@ interface IMcpServerItemTemplateData { */ export class McpServerItemRenderer implements IListRenderer { readonly templateId = 'mcpServerItem'; + private readonly _templates = new Set(); + private _focusedIndex = -1; constructor( private readonly _afterShowOutput: () => Promise, + private readonly _renderManagementActions: (element: IMcpInstalledEntry, actions: HTMLElement, disposables: DisposableStore, updateTabbability: () => void) => void, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @IHoverService private readonly hoverService: IHoverService, @@ -183,23 +215,30 @@ export class McpServerItemRenderer implements IListRenderer this.updateActionsTabbability(templateData)); + this.updateActionsTabbability(templateData); return; } @@ -368,6 +418,8 @@ export class McpServerItemRenderer implements IListRenderer this.updateActionsTabbability(templateData)); + this.updateActionsTabbability(templateData); return; } @@ -383,21 +435,101 @@ export class McpServerItemRenderer implements IListRenderer this.updateActionsTabbability(templateData)); + this.updateActionsTabbability(templateData); + } + + setFocusedIndex(index: number): void { + this._focusedIndex = index; + for (const template of this._templates) { + this.updateActionsTabbability(template); } + } - const statusElement = DOM.append(templateData.actions, $('.mcp-server-status')); - statusElement.classList.add(presentation.className, ...ThemeIcon.asClassNameArray(presentation.icon)); - statusElement.setAttribute('aria-hidden', 'true'); - templateData.actionDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), statusElement, presentation.label)); + private updateActionsTabbability(templateData: IMcpServerItemTemplateData): void { + setVirtualizedRowActionsTabbable(templateData.actions, templateData.currentIndex === this._focusedIndex); } disposeTemplate(templateData: IMcpServerItemTemplateData): void { + this._templates.delete(templateData); templateData.elementDisposables.dispose(); templateData.actionDisposables.dispose(); } } +interface IMcpMarketplaceItemTemplateData { + readonly container: HTMLElement; + readonly name: HTMLElement; + readonly description: HTMLElement; + readonly installButton: Button; + readonly elementDisposables: DisposableStore; + readonly templateDisposables: DisposableStore; + currentIndex: number; +} + +class McpMarketplaceItemRenderer implements IListRenderer { + readonly templateId = 'mcpMarketplaceItem'; + private readonly _templates = new Set(); + private _focusedIndex = -1; + + constructor( + private readonly _install: (server: IWorkbenchMcpServer, button: Button) => Promise, + ) { } + + renderTemplate(container: HTMLElement): IMcpMarketplaceItemTemplateData { + container.classList.add('plugin-list-item', 'plugin-marketplace-home-row'); + const details = DOM.append(container, $('.plugin-list-item-details')); + const name = DOM.append(DOM.append(details, $('.plugin-list-item-name-row')), $('.plugin-list-item-name')); + const description = DOM.append(details, $('.plugin-list-item-description')); + const actionContainer = DOM.append(container, $('.plugin-list-item-action')); + const installButton = new Button(actionContainer, defaultButtonStyles); + installButton.element.classList.add('plugin-list-item-install-button'); + const templateDisposables = new DisposableStore(); + templateDisposables.add(installButton); + templateDisposables.add(DOM.addDisposableGenericMouseDownListener(installButton.element, event => DOM.EventHelper.stop(event, true))); + const template = { container, name, description, installButton, elementDisposables: new DisposableStore(), templateDisposables, currentIndex: -1 }; + this._templates.add(template); + return template; + } + + renderElement(element: IMcpMarketplaceEntry, index: number, templateData: IMcpMarketplaceItemTemplateData): void { + templateData.elementDisposables.clear(); + templateData.currentIndex = index; + templateData.name.textContent = element.server.label; + templateData.description.textContent = truncateToFirstLine(element.server.description || localize('mcpNoDescription', "No description provided.")); + templateData.installButton.label = localize('install', "Install"); + templateData.installButton.enabled = true; + templateData.installButton.element.tabIndex = index === this._focusedIndex ? 0 : -1; + templateData.elementDisposables.add(templateData.installButton.onDidClick(event => { + DOM.EventHelper.stop(event, true); + void this._install(element.server, templateData.installButton); + })); + } + + setFocusedIndex(index: number): void { + this._focusedIndex = index; + for (const template of this._templates) { + template.installButton.element.tabIndex = template.currentIndex === index ? 0 : -1; + } + } + + disposeElement(_element: IMcpMarketplaceEntry, _index: number, templateData: IMcpMarketplaceItemTemplateData): void { + templateData.elementDisposables.clear(); + } + + disposeTemplate(templateData: IMcpMarketplaceItemTemplateData): void { + this._templates.delete(templateData); + templateData.elementDisposables.dispose(); + templateData.templateDisposables.dispose(); + } +} + function createMcpSignInButton(parent: HTMLElement, store: Pick, serverLabel: string): Button { const signInLabel = localize('signInToMcpServer', "Sign in to {0}", serverLabel); const signInButton = store.add(new Button(parent, { @@ -1069,6 +1201,7 @@ export class McpListWidget extends Disposable { private cardContainer!: HTMLElement; private cardScrollable!: DomScrollableElement; private cardScrollableNode!: HTMLElement; + private sectionLayoutContainer: HTMLElement | undefined; private emptyContainer!: HTMLElement; private emptyText!: HTMLElement; private emptySubtext!: HTMLElement; @@ -1098,11 +1231,17 @@ export class McpListWidget extends Disposable { private lastWidth: number = 0; private lastHeaderHeight = 0; private _layoutDeferred = false; + private readonly revealLastItemScheduler = this._register(new MutableDisposable()); + private readonly sectionScrollPositions = new Map(); private galleryCts: CancellationTokenSource | undefined; private readonly cardDisposables = this._register(new DisposableStore()); + private readonly pendingSectionLayout = this._register(new MutableDisposable()); private readonly cardListControllers = new WeakMap(); + private sectionLists: IMcpSectionList[] = []; + private collapsedSections: Set | undefined = new Set(); private readonly delayedFilter = new Delayer(200); private readonly delayedGallerySearch = new Delayer(400); + private readonly agentHostCustomizationsChanged: IObservable; constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -1123,6 +1262,7 @@ export class McpListWidget extends Disposable { @IMcpGalleryManifestService mcpGalleryManifestService: IMcpGalleryManifestService, ) { super(); + this.agentHostCustomizationsChanged = observableSignalFromEvent(this, this.agentHostCustomizationService.onDidChangeCustomizations); this.element = $('.mcp-list-widget.plugin-list-widget'); this.create(); const resizeObserver = this._register(new DOM.DisposableResizeObserver( @@ -1378,6 +1518,9 @@ export class McpListWidget extends Disposable { this.galleryCts?.dispose(true); const cts = this.galleryCts = new CancellationTokenSource(); this.gallerySnapshotLoading = true; + if (!revealMarketplace && !this.searchQuery.trim()) { + this.renderMcpHome(); + } try { const pager = await this.mcpWorkbenchService.queryGallery(undefined, cts.token); @@ -1455,9 +1598,10 @@ export class McpListWidget extends Disposable { private createCardScrollContent(...classNames: string[]): HTMLElement { const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-card-scroll-content')); content.classList.add(...classNames); + this.sectionLayoutContainer = classNames.includes('distributed-section-layout') ? content : undefined; const resizeObserver = this.cardDisposables.add(new DOM.DisposableResizeObserver( 'McpListWidget.cardScrollContent', - () => this.cardScrollable.scanDomNode(), + () => this.scheduleMcpSectionLayout(), )); this.cardDisposables.add(resizeObserver.observe(content)); return content; @@ -1488,23 +1632,214 @@ export class McpListWidget extends Disposable { } renderActions?.(header); const list = DOM.append(section, $('.plugin-card-grid')); + list.dataset.virtualizedSectionKey = className; + const collapsedSections = this.collapsedSections ??= new Set(); + setupCollapsibleSection( + headingRow, + list, + title, + this.cardDisposables, + collapsedSections.has(className), + collapsed => { + if (collapsed) { + collapsedSections.add(className); + } else { + collapsedSections.delete(className); + } + this.scheduleMcpSectionLayout(); + }, + ); this.cardListControllers.set(list, this.cardDisposables.add(new CustomizationCardListController(list, title))); return list; } + private createMcpSectionList(container: HTMLElement, label: string, entries: readonly IMcpSectionEntry[]): void { + const key = container.dataset.virtualizedSectionKey ?? label; + container.style.height = `${MCP_SECTION_ITEM_HEIGHT}px`; + container.classList.add('virtualized-section-list'); + this.cardListControllers.get(container)?.dispose(); + this.cardListControllers.delete(container); + container.removeAttribute('role'); + container.removeAttribute('aria-label'); + const itemRenderer = this.instantiationService.createInstance( + McpServerItemRenderer, + () => Promise.resolve(), + (entry, actions, disposables, updateTabbability) => this.renderMcpListActions(entry, actions, disposables, updateTabbability), + ); + const marketplaceRenderer = new McpMarketplaceItemRenderer((server, button) => this.installMarketplaceServer(server, button)); + const list = this.cardDisposables.add(this.instantiationService.createInstance( + WorkbenchList, + `McpManagementList.${label}`, + container, + new McpSectionDelegate(), + [itemRenderer, marketplaceRenderer], + { + multipleSelectionSupport: false, + setRowLineHeight: false, + horizontalScrolling: false, + accessibilityProvider: { + getAriaLabel: entry => entry.type === 'marketplace-item' + ? localize('marketplaceMcpServerRowAriaLabel', "{0}. Available to install from the MCP marketplace.", entry.server.label) + : this.getMcpEntryAriaLabel(entry), + getWidgetAriaLabel: () => label, + getSetSize: (_entry, _index, listLength) => listLength, + getPosInSet: (_entry, index) => index + 1, + }, + openOnSingleClick: true, + identityProvider: { + getId: entry => entry.type === 'marketplace-item' ? `marketplace:${entry.server.id}` : getMcpRowKey(entry), + }, + }, + )); + list.splice(0, 0, entries); + list.scrollTop = this.sectionScrollPositions.get(key) ?? 0; + this.cardDisposables.add(list.onDidOpen(event => { + const entry = event.element; + if (!entry) { + return; + } + this._onDidSelectServer.fire(entry.type === 'marketplace-item' + ? createWorkbenchMcpServerDetailInput(entry.server) + : createInstalledMcpServerDetailInput(entry)); + })); + this.cardDisposables.add(list.onContextMenu(event => { + if (event.element && event.element.type !== 'marketplace-item') { + this.showMcpServerActions(event.element, event.anchor); + } + })); + this.cardDisposables.add(list.onDidChangeFocus(event => { + const index = event.indexes[0] ?? -1; + itemRenderer.setFocusedIndex(index); + marketplaceRenderer.setFocusedIndex(index); + })); + this.cardDisposables.add(list.onDidFocus(() => { + if (list.getFocus().length === 0 && entries.length > 0) { + list.setFocus([0]); + } + })); + this.sectionLists.push({ list, entries, container, key }); + } + + private captureSectionScrollPositions(): void { + for (const section of this.sectionLists) { + this.sectionScrollPositions.set(section.key, section.list.scrollTop); + } + } + + private getMcpEntryAriaLabel(entry: IMcpInstalledEntry): IObservable { + return derived(this, reader => { + this.agentHostCustomizationsChanged.read(reader); + const label = getMcpEntryLabel(entry); + const activeSessionResource = this.customizationHarnessService.activeSessionResource.read(reader); + let statusKind: McpStatusKind | undefined; + let disabledReason: CustomizationDisabledReason | undefined; + if (entry.type === 'session-server-item') { + const server = this.agentHostCustomizationService.getMcpServers(activeSessionResource).find(server => server.id === entry.server.id) ?? entry.server; + const presentation = getActiveSessionServerPresentation(server); + statusKind = presentation.status; + disabledReason = presentation.enabled ? undefined : server.disabledReason; + } else if (entry.activeSessionServer !== undefined) { + const server = this.agentHostCustomizationService.getMcpServers(activeSessionResource).find(server => server.id === entry.activeSessionServer?.id) ?? entry.activeSessionServer; + const presentation = getActiveSessionServerPresentation(server); + statusKind = presentation.status; + disabledReason = presentation.enabled ? undefined : server.disabledReason; + } else if (entry.localServer && isContributionDisabled(entry.localServer.enablement.read(reader))) { + statusKind = 'disabled'; + disabledReason = getMcpDisabledReason(entry); + } else if (entry.type === 'server-item' && !this.workspaceService.isSessionsWindow) { + statusKind = entry.localServer?.connectionState.read(reader).state; + } + const status = getMcpStatusPresentation(statusKind, disabledReason); + return status ? localize('mcpServerAriaLabelWithStatus', "{0}, {1}", label, status.label) : label; + }); + } + + private renderMcpListActions(entry: IMcpInstalledEntry, actions: HTMLElement, disposables: DisposableStore, updateTabbability: () => void): void { + const label = getMcpEntryLabel(entry); + let enabled = this.isInstalledEntryEnabled(entry); + const switchElement = DOM.append(actions, $('button.plugin-enable-switch')) as HTMLButtonElement; + switchElement.type = 'button'; + switchElement.setAttribute('role', 'switch'); + DOM.append(switchElement, $('.plugin-enable-switch-thumb')); + const update = () => { + enabled = this.isInstalledEntryEnabled(entry); + const blockedByPlugin = getMcpDisabledReason(entry)?.source === 'plugin'; + const toggleLabel = enabled ? localize('disableMcpServerAria', "Disable {0}", label) : localize('enableMcpServerAria', "Enable {0}", label); + const accessibleLabel = blockedByPlugin ? localize('mcpServerManagedByPluginAria', "{0} is disabled by its plugin", label) : toggleLabel; + switchElement.disabled = blockedByPlugin; + switchElement.classList.toggle('checked', enabled); + switchElement.setAttribute('aria-checked', String(enabled)); + switchElement.setAttribute('aria-label', accessibleLabel); + switchElement.title = accessibleLabel; + updateTabbability(); + }; + update(); + disposables.add(DOM.addDisposableGenericMouseDownListener(switchElement, event => DOM.EventHelper.stop(event, true))); + disposables.add(DOM.addDisposableListener(switchElement, 'click', event => { + DOM.EventHelper.stop(event, true); + enabled = !enabled; + this.setInstalledEntryEnabled(entry, enabled); + update(); + status(enabled ? localize('mcpServerEnabledStatus', "{0} enabled.", label) : localize('mcpServerDisabledStatus', "{0} disabled.", label)); + })); + if (entry.type !== 'session-server-item' && entry.localServer) { + disposables.add(autorun(reader => { + entry.localServer?.enablement.read(reader); + update(); + })); + } + disposables.add(this.agentHostCustomizationService.onDidChangeCustomizations(update)); + + const more = disposables.add(new Button(actions, { + ...getButtonStyles({ buttonSecondaryBackground: undefined, buttonSecondaryBorder: undefined }), + secondary: true, + supportIcons: true, + ariaLabel: localize('mcpMoreActionsAria', "More actions for {0}", label), + })); + more.element.classList.add('plugin-card-icon-button'); + more.label = `$(${Codicon.ellipsis.id})`; + registerMcpInlineButtonAction(disposables, more, () => this.showMcpServerActions(entry, more.element)); + } + + private layoutMcpSectionLists(): void { + const content = this.sectionLayoutContainer; + if (!content) { + return; + } + const heights = layoutVirtualizedSections(content, this.sectionLists.map(section => ({ + container: section.container, + contentHeight: section.entries.length * MCP_SECTION_ITEM_HEIGHT, + minimumHeight: MCP_SECTION_ITEM_HEIGHT, + }))); + for (let index = 0; index < this.sectionLists.length; index++) { + const section = this.sectionLists[index]; + const height = heights[index]; + layoutVirtualizedSectionList(section.list, section.container, height, section.container.clientWidth || undefined); + } + } + + private scheduleMcpSectionLayout(): void { + this.pendingSectionLayout.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.element), () => { + this.layoutMcpSectionLists(); + this.cardScrollable.scanDomNode(); + }); + } + private renderMcpHome(): void { if (this.searchQuery.trim()) { return; } + this.captureSectionScrollPositions(); this.cardDisposables.clear(); + this.sectionLists = []; this.installedAddButton = undefined; this.firstCardFocusElement = undefined; this.availableSection = undefined; DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = this.createCardScrollContent(); + const content = this.createCardScrollContent('distributed-section-layout'); this.renderFeaturedServers(content); const installedList = this.renderCardSection( @@ -1520,13 +1855,11 @@ export class McpListWidget extends Disposable { const empty = DOM.append(installedList, $('.plugin-inventory-empty')); empty.textContent = localize('noInstalledMcpServers', "No MCP servers are installed."); } else { - for (const presentation of this.installedEntries) { - this.appendInstalledServerRow(installedList, presentation); - } + this.createMcpSectionList(installedList, localize('installedMcpServersSection', "Installed"), this.installedEntries.map(presentation => presentation.entry)); } - this.cardListControllers.get(installedList)?.finalize(); this.renderAvailableServers(content, this.getAvailableGalleryServers(), true); + this.scheduleMcpSectionLayout(); } private renderInstalledSectionActions(header: HTMLElement): void { @@ -1565,10 +1898,8 @@ export class McpListWidget extends Disposable { localize('featuredMcpServersDescription', "Discover MCP servers that connect agents to popular tools and services."), 'plugin-discovery-section', ); - for (const server of featured) { - this.appendMarketplaceServerCard(grid, server); - } - this.cardListControllers.get(grid)?.finalize(); + grid.classList.add('plugin-inventory-list'); + this.createMcpSectionList(grid, localize('featuredMcpServers', "Featured"), featured.map(server => ({ type: 'marketplace-item', server }))); } private renderAvailableServers(parent: HTMLElement, servers: readonly IWorkbenchMcpServer[], showDescription: boolean): void { @@ -1582,20 +1913,19 @@ export class McpListWidget extends Disposable { this.availableSection = availableList.parentElement ?? undefined; availableList.classList.add('plugin-inventory-list'); if (servers.length === 0) { - const empty = DOM.append(availableList, $('.plugin-inventory-empty')); - empty.textContent = this.gallerySnapshotLoading - ? localize('loadingMcpMarketplace', "Loading marketplace MCP servers...") - : localize('noAvailableMcpServers', "No marketplace MCP servers are available."); + if (this.gallerySnapshotLoading) { + renderVirtualizedSectionLoadingPlaceholder(availableList, localize('loadingMcpMarketplace', "Loading marketplace MCP servers..."), MCP_SECTION_ITEM_HEIGHT); + } else { + const empty = DOM.append(availableList, $('.plugin-inventory-empty')); + empty.textContent = localize('noAvailableMcpServers', "No marketplace MCP servers are available."); + } this.cardListControllers.get(availableList)?.finalize(); return; } - for (const server of servers) { - this.appendMarketplaceServerRow(availableList, server); - } - this.cardListControllers.get(availableList)?.finalize(); + this.createMcpSectionList(availableList, localize('availableMcpServersSection', "Available"), servers.map(server => ({ type: 'marketplace-item', server }))); } - private appendInstalledServerRow(parent: HTMLElement, presentation: IMcpInstalledPresentation): void { + protected appendInstalledServerRow(parent: HTMLElement, presentation: IMcpInstalledPresentation): void { let entry = presentation.entry; const rowKey = getMcpRowKey(entry); const label = getMcpEntryLabel(entry); @@ -1727,7 +2057,7 @@ export class McpListWidget extends Disposable { return { element: switchElement, update }; } - private appendMarketplaceServerRow(parent: HTMLElement, server: IWorkbenchMcpServer): void { + protected appendMarketplaceServerRow(parent: HTMLElement, server: IWorkbenchMcpServer): void { const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.plugin-marketplace-home-row')); const primaryAction = this.addSurfaceActivation(row, localize('marketplaceMcpServerRowAriaLabel', "{0}. Available to install from the MCP marketplace.", server.label), () => this._onDidSelectServer.fire(createWorkbenchMcpServerDetailInput(server))); const details = DOM.append(primaryAction, $('.plugin-list-item-details')); @@ -1750,27 +2080,6 @@ export class McpListWidget extends Disposable { }); } - private appendMarketplaceServerCard(parent: HTMLElement, server: IWorkbenchMcpServer): void { - const card = DOM.append(parent, $('.plugin-card.plugin-marketplace-card')); - const header = DOM.append(card, $('.plugin-card-header')); - const titleBlock = this.addSurfaceActivation(header, localize('marketplaceMcpServerCardAriaLabel', "{0}. Featured MCP server available to install.", server.label), () => this._onDidSelectServer.fire(createWorkbenchMcpServerDetailInput(server)), 'plugin-card-title-block'); - const name = DOM.append(titleBlock, $('.plugin-card-title')); - name.textContent = server.label; - name.title = server.label; - const description = DOM.append(titleBlock, $('.plugin-card-subtitle')); - description.textContent = truncateToFirstLine(server.description || localize('mcpNoDescription', "No description provided.")); - const actions = DOM.append(header, $('.plugin-card-actions')); - const install = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, ariaLabel: localize('installMcpServerAria', "Install {0}", server.label) })); - install.label = localize('install', "Install"); - this.cardDisposables.add(install.onDidClick(() => this.installMarketplaceServer(server, install))); - this.cardListControllers.get(parent)?.addItem({ - row: card, - primaryAction: titleBlock, - label: server.label, - actions: [install.element], - }); - } - private async installMarketplaceServer(server: IWorkbenchMcpServer, button: Button): Promise { button.label = localize('installing', "Installing..."); button.enabled = false; @@ -1858,24 +2167,24 @@ export class McpListWidget extends Disposable { return; } + this.captureSectionScrollPositions(); this.cardDisposables.clear(); + this.sectionLists = []; this.installedAddButton = undefined; this.firstCardFocusElement = undefined; this.availableSection = undefined; DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = this.createCardScrollContent('plugin-search-results'); + const content = this.createCardScrollContent('plugin-search-results', 'distributed-section-layout'); if (this.installedEntries.length > 0) { const installedList = this.renderCardSection(content, localize('installedSearchHeader', "Installed"), undefined, 'installed-mcp-servers-section', this.installedEntries.length); installedList.classList.add('plugin-inventory-list'); - for (const presentation of this.installedEntries) { - this.appendInstalledServerRow(installedList, presentation); - } - this.cardListControllers.get(installedList)?.finalize(); + this.createMcpSectionList(installedList, localize('installedSearchHeader', "Installed"), this.installedEntries.map(presentation => presentation.entry)); } if (available.length > 0) { this.renderAvailableServers(content, available, false); } + this.scheduleMcpSectionLayout(); } private filterServers(render = true): void { @@ -2003,6 +2312,9 @@ export class McpListWidget extends Disposable { layout(height: number, width: number): void { this.lastHeight = height; this.lastWidth = width; + if (!this.visible || this.element.parentElement?.style.display === 'none') { + return; + } this.element.style.height = `${height}px`; this.updateResponsiveLayout(width); @@ -2030,7 +2342,7 @@ export class McpListWidget extends Disposable { const listHeight = Math.max(0, availableHeight - searchBarHeight - headerHeight); this.cardScrollableNode.style.height = `${listHeight}px`; - this.cardScrollable.scanDomNode(); + this.scheduleMcpSectionLayout(); } /** @@ -2044,7 +2356,16 @@ export class McpListWidget extends Disposable { * Scrolls the list so the last item is visible. */ revealLastItem(): void { - this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); + const reveal = () => { + const section = this.sectionLists.at(-1); + if (section?.entries.length) { + section.list.reveal(section.entries.length - 1); + } + this.cardScrollable.scanDomNode(); + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); + }; + reveal(); + this.revealLastItemScheduler.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.element), reveal); } /** @@ -2052,7 +2373,15 @@ export class McpListWidget extends Disposable { */ focus(): void { if (this.cardScrollableNode.style.display !== 'none') { - this.firstCardFocusElement?.focus(); + if (this.firstCardFocusElement) { + this.firstCardFocusElement.focus(); + } else { + const section = this.sectionLists[0]; + if (section?.entries.length) { + section.list.setFocus([0]); + section.list.domFocus(); + } + } } } @@ -2071,7 +2400,7 @@ export class McpListWidget extends Disposable { } } - private showMcpServerActions(entry: IMcpInstalledEntry, anchor: HTMLElement): void { + private showMcpServerActions(entry: IMcpInstalledEntry, anchor: HTMLElement | IMouseEvent): void { const disposables = new DisposableStore(); const actions = this.getMcpServerActions(entry, disposables); if (actions.length === 0) { 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 bfb2ea45c93177..475687031de553 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -796,11 +796,6 @@ per-word capitalization does not survive translation. */ line-height: 1.45; color: var(--vscode-descriptionForeground); margin: 0 0 8px 0; - /* Reserve space for ~2 lines so the header height is stable across sections. */ - /* Rounded up to a whole pixel so naturally-wrapped 2-line descriptions and */ - /* shorter 1-line descriptions render at the same height instead of differing */ - /* by a sub-pixel rounding. */ - min-height: 38px; } .ai-customization-list-widget .section-title-header .section-title-link, @@ -962,16 +957,25 @@ per-word capitalization does not survive translation. */ flex: 1; min-height: 0; width: 100%; - max-width: calc(840px + var(--vscode-spacing-size160)); + max-width: 840px; margin-inline: auto; } +.ai-customization-management-editor .prompt-migration-content-container > .section-title-header, +.ai-customization-management-editor .prompt-migration-content-container > .customization-migration-banner, +.ai-customization-management-editor .prompt-migration-content-container > .prompt-migration-list-scrollable, +.ai-customization-management-editor .prompt-migration-content-container > .prompt-migration-footer { + width: min(100%, 840px); + margin-inline: auto; + box-sizing: border-box; +} + .ai-customization-management-editor .prompt-migration-footer { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; - gap: var(--vscode-spacing-size120); + gap: var(--vscode-spacing-size80); padding-top: var(--vscode-spacing-size120); border-top: var(--vscode-strokeThickness) solid var(--vscode-widget-border); } @@ -1020,7 +1024,7 @@ per-word capitalization does not survive translation. */ /* Migration banner — an Inner-tier callout for customizations needing attention. */ .ai-customization-management-editor .customization-migration-banner { flex-shrink: 0; - margin: var(--vscode-spacing-size120) 0; + margin-block: var(--vscode-spacing-size120); padding: var(--vscode-spacing-size120); background: color-mix(in srgb, var(--vscode-inputValidation-warningBackground) 35%, var(--vscode-agentsPanel-background)); border: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--vscode-inputValidation-warningBorder) 70%, var(--vscode-widget-border)); @@ -1071,18 +1075,15 @@ per-word capitalization does not survive translation. */ display: flex; flex-direction: column; overflow: hidden; - padding: 0 var(--vscode-spacing-size160) var(--vscode-spacing-size60) 0; + gap: var(--vscode-spacing-size100); + padding: 0; box-sizing: border-box; } .ai-customization-management-editor .prompt-migration-group { display: flex; flex-direction: column; - margin-bottom: var(--vscode-spacing-size200); -} - -.ai-customization-management-editor .prompt-migration-group + .prompt-migration-group { - margin-top: var(--vscode-spacing-size40); + margin: 0; } .ai-customization-management-editor .prompt-migration-group-header { @@ -1102,6 +1103,36 @@ per-word capitalization does not survive translation. */ min-width: 0; } +.ai-customization-management-editor .customization-section-toggle { + flex: 0 0 auto; + width: var(--vscode-spacing-size160); + height: var(--vscode-spacing-size160); + padding: 0; + border: 0; + border-radius: var(--vscode-cornerRadius-small); + background: transparent; + color: var(--vscode-icon-foreground); + line-height: var(--vscode-spacing-size160); + cursor: pointer; +} + +.ai-customization-management-editor .customization-section-toggle:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.ai-customization-management-editor .customization-section-toggle:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.ai-customization-management-editor .distributed-section-layout.virtualized-section-layout-overflow { + padding-right: var(--vscode-spacing-size100); +} + +.vscode-high-contrast .ai-customization-management-editor .customization-section-toggle:focus-visible { + outline-color: var(--vscode-contrastActiveBorder); +} + .ai-customization-management-editor .prompt-migration-group-title { margin: 0; font-size: var(--vscode-agents-fontSize-heading3); @@ -1145,6 +1176,14 @@ per-word capitalization does not survive translation. */ overflow: hidden; } +.ai-customization-management-editor .prompt-migration-group-items.virtualized-section-list { + display: block; +} + +.ai-customization-management-editor .prompt-migration-group-items.virtualized-section-list .monaco-list-row { + box-sizing: border-box; +} + .ai-customization-management-editor .prompt-migration-group-empty { padding: var(--vscode-spacing-size160); color: var(--vscode-descriptionForeground); @@ -1161,8 +1200,8 @@ per-word capitalization does not survive translation. */ box-sizing: border-box; } -.ai-customization-management-editor .prompt-migration-item:not(:last-child) { - box-shadow: inset 0 calc(-1 * var(--vscode-strokeThickness)) var(--vscode-widget-border); +.ai-customization-management-editor .prompt-migration-group-items.virtualized-section-list .monaco-list-row:not(:first-child) { + box-shadow: inset 0 var(--vscode-strokeThickness) var(--vscode-widget-border); } .ai-customization-management-editor .prompt-migration-checkbox { @@ -1341,7 +1380,7 @@ per-word capitalization does not survive translation. */ flex: 1; min-height: 0; width: 100%; - max-width: calc(840px + var(--vscode-spacing-size160)); + max-width: 840px; margin: 0 auto; } @@ -1352,8 +1391,8 @@ per-word capitalization does not survive translation. */ overflow: hidden; display: flex; flex-direction: column; - gap: var(--vscode-spacing-size240); - padding: 0 var(--vscode-spacing-size160) var(--vscode-spacing-size200) 0; + gap: var(--vscode-spacing-size100); + padding: 0 0 var(--vscode-spacing-size200); box-sizing: border-box; } @@ -1465,23 +1504,14 @@ per-word capitalization does not survive translation. */ outline-offset: -1px; } -.ai-customization-management-editor .tools-list-setrow:not(:last-child), -.ai-customization-management-editor .tools-list-children:not(:last-child) .tools-list-toolrow:last-child { - box-shadow: inset 0 calc(-1 * var(--vscode-strokeThickness)) var(--vscode-widget-border); -} - .ai-customization-management-editor .tools-list-setrow:hover, .ai-customization-management-editor .tools-list-toolrow:hover { background: var(--vscode-list-hoverBackground); } -/* Match the other customization lists' focus ring: :focus-within for inner checkbox/chevron, :focus for a row click. */ -.ai-customization-management-editor .tools-list-setrow:focus, -.ai-customization-management-editor .tools-list-setrow:focus-within, -.ai-customization-management-editor .tools-list-toolrow:focus, -.ai-customization-management-editor .tools-list-toolrow:focus-within { - outline: 1px solid var(--vscode-list-focusOutline); - background: var(--vscode-list-focusBackground, var(--vscode-list-hoverBackground)); +/* A row-to-row divider between every adjacent row (set-to-set, set-to-tool, tool-to-tool, tool-to-set). */ +.ai-customization-management-editor .tools-inventory-list .monaco-list-row:not(:first-child) { + box-shadow: inset 0 var(--vscode-strokeThickness) var(--vscode-widget-border); } .ai-customization-management-editor .tools-list-chevron { @@ -1498,13 +1528,7 @@ per-word capitalization does not survive translation. */ margin-left: auto; } -/* Child tool row (indented under the tool set) */ -.ai-customization-management-editor .tools-list-children { - display: flex; - flex-direction: column; - gap: 2px; -} - +/* Tool row: visually indented under its tool set via its own `padding-inline-start` (no DOM nesting). */ .ai-customization-management-editor .tools-list-toolrow { display: flex; align-items: center; @@ -2520,6 +2544,26 @@ per-word capitalization does not survive translation. */ overflow: hidden; } +.plugin-list-widget .plugin-card-grid.plugin-inventory-list.virtualized-section-list { + display: block; +} + +.plugin-list-widget .virtualized-section-list .monaco-list-row { + box-sizing: border-box; +} + +.plugin-list-widget .customization-inventory-list .monaco-list-row.ai-customization-list-item { + background-color: var(--vscode-editorWidget-background); +} + +.plugin-list-widget .customization-inventory-list .monaco-list-row.ai-customization-list-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.plugin-list-widget .virtualized-section-list .monaco-list-row:not(:first-child) { + box-shadow: inset 0 var(--vscode-strokeThickness) var(--vscode-widget-border, var(--vscode-agentsPanel-border)); +} + .plugin-list-widget .plugin-home-row { width: 100%; min-height: 64px; @@ -2685,10 +2729,15 @@ per-word capitalization does not survive translation. */ } .plugin-list-widget .plugin-list-item-action { + display: flex; + align-items: center; + flex-flow: row nowrap; + gap: var(--vscode-spacing-size40); flex-shrink: 0; + margin-left: auto; } -.plugin-list-widget .plugin-home-row .plugin-card-icon-button.monaco-button { +.plugin-list-widget .plugin-list-item-action .plugin-card-icon-button.monaco-button { width: var(--vscode-spacing-size240); min-width: var(--vscode-spacing-size240); height: var(--vscode-spacing-size240); @@ -2698,12 +2747,33 @@ per-word capitalization does not survive translation. */ color: var(--vscode-descriptionForeground); } -.plugin-list-widget .plugin-home-row .plugin-card-icon-button.monaco-button:hover { +.plugin-list-widget .plugin-list-item-action .plugin-card-icon-button.monaco-button:hover { + background: var(--vscode-toolbar-hoverBackground); + border-color: transparent; + color: var(--vscode-textLink-activeForeground); +} + +.plugin-list-widget .mcp-server-actions .plugin-card-icon-button.monaco-button { + width: var(--vscode-spacing-size240); + min-width: var(--vscode-spacing-size240); + height: var(--vscode-spacing-size240); + padding: 0; + border-color: transparent; background: transparent; - border-color: var(--vscode-widget-border); + color: var(--vscode-descriptionForeground); +} + +.plugin-list-widget .mcp-server-actions .plugin-card-icon-button.monaco-button:hover { + background: var(--vscode-toolbar-hoverBackground); + border-color: transparent; color: var(--vscode-textLink-activeForeground); } +.vscode-high-contrast .plugin-list-widget .plugin-list-item-action .plugin-card-icon-button.monaco-button:focus-visible, +.vscode-high-contrast .plugin-list-widget .mcp-server-actions .plugin-card-icon-button.monaco-button:focus-visible { + border-color: var(--vscode-contrastActiveBorder); +} + .plugin-list-widget .customization-home-row:hover { background: color-mix(in srgb, var(--vscode-list-hoverBackground) 65%, transparent); } @@ -2730,7 +2800,7 @@ per-word capitalization does not survive translation. */ flex: 1; min-height: 0; width: 100%; - max-width: calc(840px + var(--vscode-spacing-size160)); + max-width: 840px; margin: 0 auto; } @@ -2742,14 +2812,33 @@ per-word capitalization does not survive translation. */ .plugin-list-widget .plugin-card-scroll { height: 100%; overflow: auto; - padding: 0 max(var(--vscode-spacing-size20), calc((100% - 840px) / 2)) var(--vscode-spacing-size200); + padding: 0 0 var(--vscode-spacing-size200); box-sizing: border-box; } +.plugin-list-widget .plugin-card-scroll.distributed-section-layout { + display: flex; + flex-direction: column; + gap: var(--vscode-spacing-size100); + height: 100%; + min-height: 0; + padding-bottom: 0; + overflow: hidden; +} + +.plugin-list-widget .distributed-section-layout > .plugin-card-section { + flex: 0 0 auto; + margin-bottom: 0; +} + +.plugin-list-widget .distributed-section-layout .plugin-card-section-header { + margin-bottom: var(--vscode-spacing-size20); +} + .plugin-list-widget .plugin-card-scroll-content { height: auto; overflow: visible; - padding: 0 var(--vscode-spacing-size160) var(--vscode-spacing-size200) 0; + padding: 0 0 var(--vscode-spacing-size200); } .plugin-list-widget .plugin-marketplace-back-container { @@ -2763,10 +2852,6 @@ per-word capitalization does not survive translation. */ margin-bottom: var(--vscode-spacing-size240); } -.plugin-list-widget .plugin-discovery-section { - margin-bottom: var(--vscode-spacing-size320); -} - .plugin-list-widget .plugin-card-section-header { display: flex; align-items: flex-end; @@ -2863,6 +2948,15 @@ per-word capitalization does not survive translation. */ line-height: 16px; } +.plugin-list-widget .virtualized-section-loading { + display: flex; + align-items: center; + box-sizing: border-box; + padding-inline: var(--vscode-spacing-size160); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body2); +} + .plugin-list-widget .plugin-marketplace-home-row .plugin-list-item-install-button.monaco-button { width: auto; flex: 0 0 auto; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index a4f1b956ff429c..edb1ed8d80fcdd 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -15,7 +15,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Button, ButtonWithDropdown } from '../../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles, getButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; -import { autorun } from '../../../../../base/common/observable.js'; +import { autorun, derived, IObservable } from '../../../../../base/common/observable.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { URI } from '../../../../../base/common/uri.js'; import { InputBox, MessageType } from '../../../../../base/browser/ui/inputbox/inputBox.js'; @@ -47,7 +47,7 @@ import { INotificationService } from '../../../../../platform/notification/commo import { getErrorMessage } from '../../../../../base/common/errors.js'; import { getPluginInclusionLabel } from './aiCustomizationPresentation.js'; import { status } from '../../../../../base/browser/ui/aria/aria.js'; -import { createCustomizationCardPrimaryAction, CustomizationCardListController } from './customizationCardList.js'; +import { createCustomizationCardPrimaryAction, CustomizationCardListController, layoutVirtualizedSectionList, layoutVirtualizedSections, renderVirtualizedSectionLoadingPlaceholder, setVirtualizedRowActionsTabbable, setupCollapsibleSection } from './customizationCardList.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; @@ -151,6 +151,13 @@ interface IPluginSearchHeaderEntry { type IPluginListEntry = IPluginGroupHeaderEntry | IPluginSearchHeaderEntry | IPluginInstalledItemEntry | IPluginMarketplaceItemEntry | IPluginRemoteItemEntry; +interface IPluginSectionList { + readonly list: WorkbenchList; + readonly entries: readonly IPluginListEntry[]; + readonly container: HTMLElement; + readonly key: string; +} + //#endregion //#region Delegate @@ -225,14 +232,20 @@ interface IPluginInstalledItemTemplateData { readonly source: HTMLElement; readonly description: HTMLElement; readonly metadata: HTMLElement; + readonly actions: HTMLElement; readonly disposables: DisposableStore; + currentIndex: number; } class PluginInstalledItemRenderer implements IListRenderer { readonly templateId = 'pluginInstalledItem'; + private readonly _templates = new Set(); + private _focusedIndex = -1; constructor( private readonly _harnessService: ICustomizationHarnessService, + private readonly _renderActions: (item: IInstalledPluginItem, container: HTMLElement, actions: HTMLElement, disposables: DisposableStore) => void, + private readonly _showSyncCheckbox = true, ) { } renderTemplate(container: HTMLElement): IPluginInstalledItemTemplateData { @@ -245,12 +258,16 @@ class PluginInstalledItemRenderer implements IListRenderer { readonly templateId = 'pluginRemoteItem'; + private readonly _templates = new Set(); + private _focusedIndex = -1; + + constructor( + private readonly _renderActions: (item: ICustomizationItem, actions: HTMLElement, disposables: DisposableStore) => void, + ) { } renderTemplate(container: HTMLElement): IPluginRemoteItemTemplateData { container.classList.add('plugin-list-item', 'plugin-remote-item'); @@ -325,11 +362,16 @@ class PluginRemoteItemRenderer implements IListRenderer): string { @@ -403,18 +457,22 @@ interface IPluginMarketplaceItemTemplateData { readonly installButton: Button; readonly elementDisposables: DisposableStore; readonly templateDisposables: DisposableStore; + currentIndex: number; } const PLUGIN_MARKETPLACE_ITEM_TEMPLATE_ID = 'pluginMarketplaceItem'; class PluginMarketplaceItemRenderer implements IListRenderer { readonly templateId = PLUGIN_MARKETPLACE_ITEM_TEMPLATE_ID; + private readonly _templates = new Set(); + private _focusedIndex = -1; constructor( private readonly pluginInstallService: IPluginInstallService, private readonly agentPluginService: IAgentPluginService, private readonly pluginMarketplaceService: IPluginMarketplaceService, private readonly notificationService: INotificationService, + private readonly showRecommendedBadge = true, ) { } renderTemplate(container: HTMLElement): IPluginMarketplaceItemTemplateData { @@ -433,15 +491,19 @@ class PluginMarketplaceItemRenderer implements IListRenderer DOM.EventHelper.stop(event, true))); - return { container, name, recommendedBadge, publisher, description, metadata, installButton, elementDisposables: new DisposableStore(), templateDisposables }; + const template = { container, name, recommendedBadge, publisher, description, metadata, installButton, elementDisposables: new DisposableStore(), templateDisposables, currentIndex: -1 }; + this._templates.add(template); + return template; } - renderElement(element: IPluginMarketplaceItemEntry, _index: number, templateData: IPluginMarketplaceItemTemplateData): void { + renderElement(element: IPluginMarketplaceItemEntry, index: number, templateData: IPluginMarketplaceItemTemplateData): void { templateData.elementDisposables.clear(); + templateData.currentIndex = index; templateData.name.textContent = element.item.name; - templateData.recommendedBadge.style.display = this.isRecommended(element.item) ? '' : 'none'; + templateData.recommendedBadge.style.display = this.showRecommendedBadge && this.isRecommended(element.item) ? '' : 'none'; templateData.publisher.textContent = ''; templateData.publisher.style.display = 'none'; templateData.description.textContent = element.item.description || ''; @@ -463,13 +525,16 @@ class PluginMarketplaceItemRenderer implements IListRenderer { + templateData.elementDisposables.add(templateData.installButton.onDidClick(async event => { + DOM.EventHelper.stop(event, true); templateData.installButton.label = localize('installing', "Installing..."); templateData.installButton.enabled = false; try { @@ -485,19 +550,33 @@ class PluginMarketplaceItemRenderer implements IListRenderer; private emptyContainer!: HTMLElement; @@ -659,7 +739,10 @@ export class PluginListWidget extends Disposable { private updatePluginsButton!: Button; private readonly addDropdownActions = this._register(new DisposableStore()); private readonly cardDisposables = this._register(new DisposableStore()); + private readonly pendingSectionLayout = this._register(new MutableDisposable()); private readonly cardListControllers = new WeakMap(); + private sectionLists: IPluginSectionList[] = []; + private collapsedSections: Set | undefined = new Set(); private installedItems: IInstalledPluginItem[] = []; private remoteItems: ICustomizationItem[] = []; @@ -675,7 +758,9 @@ export class PluginListWidget extends Disposable { private lastWidth: number = 0; private lastHeaderHeight = 0; private _layoutDeferred = false; + private readonly revealLastItemScheduler = this._register(new MutableDisposable()); private readonly collapsedGroups = new Set(); + private readonly sectionScrollPositions = new Map(); private marketplaceCts: CancellationTokenSource | undefined; private marketplaceSnapshotCts: CancellationTokenSource | undefined; private readonly delayedFilter = new Delayer(200); @@ -891,8 +976,8 @@ export class PluginListWidget extends Disposable { const delegate = new PluginItemDelegate(); const groupHeaderRenderer = new CustomizationGroupHeaderRenderer('pluginGroupHeader', this.hoverService); const searchHeaderRenderer = new PluginSearchHeaderRenderer(); - const installedRenderer = new PluginInstalledItemRenderer(this.harnessService); - const remoteRenderer = new PluginRemoteItemRenderer(); + const installedRenderer = new PluginInstalledItemRenderer(this.harnessService, (item, container, actions, disposables) => this.renderInstalledListActions(item, container, actions, disposables)); + const remoteRenderer = new PluginRemoteItemRenderer((item, actions, disposables) => this.renderRemoteListActions(item, actions, disposables)); const marketplaceRenderer = new PluginMarketplaceItemRenderer(this.pluginInstallService, this.agentPluginService, this.pluginMarketplaceService, this.notificationService); this.list = this._register(this.instantiationService.createInstance( @@ -984,6 +1069,12 @@ export class PluginListWidget extends Disposable { } } })); + this._register(this.list.onDidChangeFocus(event => { + const index = event.indexes[0] ?? -1; + installedRenderer.setFocusedIndex(index); + remoteRenderer.setFocusedIndex(index); + marketplaceRenderer.setFocusedIndex(index); + })); // Handle context menu this._register(this.list.onContextMenu(e => this.onContextMenu(e as IListContextMenuEvent))); @@ -1213,9 +1304,10 @@ export class PluginListWidget extends Disposable { private createCardScrollContent(...classNames: string[]): HTMLElement { const content = DOM.append(this.cardContainer, $('.plugin-card-scroll.plugin-card-scroll-content')); content.classList.add(...classNames); + this.sectionLayoutContainer = classNames.includes('distributed-section-layout') ? content : undefined; const resizeObserver = this.cardDisposables.add(new DOM.DisposableResizeObserver( 'PluginListWidget.cardScrollContent', - () => this.cardScrollable.scanDomNode(), + () => this.schedulePluginSectionLayout(), )); this.cardDisposables.add(resizeObserver.observe(content)); return content; @@ -1248,29 +1340,240 @@ export class PluginListWidget extends Disposable { } renderActions?.(header); const list = DOM.append(section, $('.plugin-card-grid')); + const sectionKey = className ?? title; + list.dataset.virtualizedSectionKey = sectionKey; + const collapsedSections = this.collapsedSections ??= new Set(); + setupCollapsibleSection( + headingRow, + list, + title, + this.cardDisposables, + collapsedSections.has(sectionKey), + collapsed => { + if (collapsed) { + collapsedSections.add(sectionKey); + } else { + collapsedSections.delete(sectionKey); + } + this.schedulePluginSectionLayout(); + }, + ); this.cardListControllers.set(list, this.cardDisposables.add(new CustomizationCardListController(list, title))); return list; } + private createPluginSectionList(container: HTMLElement, label: string, entries: readonly IPluginListEntry[], showRecommendedBadge = true): void { + const key = container.dataset.virtualizedSectionKey ?? label; + const delegate = new PluginItemDelegate(); + container.style.height = `${entries.length > 0 ? delegate.getHeight(entries[0]) : PLUGIN_ITEM_HEIGHT}px`; + container.classList.add('virtualized-section-list'); + this.cardListControllers.get(container)?.dispose(); + this.cardListControllers.delete(container); + container.removeAttribute('role'); + container.removeAttribute('aria-label'); + const installedRenderer = new PluginInstalledItemRenderer(this.harnessService, (item, row, actions, disposables) => this.renderInstalledListActions(item, row, actions, disposables), false); + const remoteRenderer = new PluginRemoteItemRenderer((item, actions, disposables) => this.renderRemoteListActions(item, actions, disposables)); + const marketplaceRenderer = new PluginMarketplaceItemRenderer(this.pluginInstallService, this.agentPluginService, this.pluginMarketplaceService, this.notificationService, showRecommendedBadge); + const list = this.cardDisposables.add(this.instantiationService.createInstance( + WorkbenchList, + `PluginManagementList.${label}`, + container, + delegate, + [installedRenderer, remoteRenderer, marketplaceRenderer], + { + multipleSelectionSupport: false, + setRowLineHeight: false, + horizontalScrolling: false, + accessibilityProvider: { + getAriaLabel: element => this.getPluginEntryAriaLabel(element), + getWidgetAriaLabel: () => label, + getSetSize: (_element, _index, listLength) => listLength, + getPosInSet: (_element, index) => index + 1, + }, + openOnSingleClick: true, + identityProvider: { getId: element => this.getPluginEntryId(element) }, + }, + )); + list.splice(0, 0, entries); + list.scrollTop = this.sectionScrollPositions.get(key) ?? 0; + this.cardDisposables.add(list.onDidOpen(event => { + const entry = event.element; + if (entry?.type === 'plugin-item' || entry?.type === 'marketplace-item') { + this._onDidSelectPlugin.fire(entry.item); + } + })); + this.cardDisposables.add(list.onContextMenu(event => this.onContextMenu(event))); + this.cardDisposables.add(list.onDidChangeFocus(event => { + const index = event.indexes[0] ?? -1; + installedRenderer.setFocusedIndex(index); + remoteRenderer.setFocusedIndex(index); + marketplaceRenderer.setFocusedIndex(index); + })); + this.cardDisposables.add(list.onDidFocus(() => { + if (list.getFocus().length === 0 && entries.length > 0) { + list.setFocus([0]); + } + })); + this.sectionLists.push({ list, entries, container, key }); + } + + private captureSectionScrollPositions(): void { + for (const section of this.sectionLists) { + this.sectionScrollPositions.set(section.key, section.list.scrollTop); + } + } + + private getPluginEntryAriaLabel(element: IPluginListEntry): string | IObservable { + if (element.type === 'group-header' || element.type === 'search-header') { + return element.label; + } + const name = formatDisplayName(element.item.name); + const description = element.item.description ? truncateToFirstLine(element.item.description) : undefined; + const nameAndDescription = description ? localize('pluginItemAriaLabel', "{0}. {1}", name, description) : name; + if (element.type === 'plugin-item') { + const metadata = getInstalledPluginMetadata(element.item); + const withMetadata = metadata ? localize('pluginInstalledItemAriaLabelWithMetadata', "{0}. {1}", nameAndDescription, metadata) : nameAndDescription; + return derived(this, reader => isContributionEnabled(element.item.plugin.enablement.read(reader)) + ? localize('pluginInstalledItemAriaLabelEnabled', "{0}. Enabled", withMetadata) + : localize('pluginInstalledItemAriaLabelDisabled', "{0}. Disabled", withMetadata)); + } + if (element.type === 'remote-item') { + const statusLabel = getRemotePluginStatusLabel(element.item); + return statusLabel + ? localize('pluginRemoteItemAriaLabelWithStatus', "{0}. Remote agent host. Status: {1}", nameAndDescription, statusLabel) + : localize('pluginRemoteItemAriaLabel', "{0}. Remote agent host", nameAndDescription); + } + const marketplaceLabel = localize('pluginMarketplaceItemAriaLabel', "{0}. From {1}", nameAndDescription, element.item.marketplace); + return this.pluginMarketplaceService.recommendedPlugins.get().has(getMarketplaceRecommendationKey(element.item)) + ? localize('pluginMarketplaceItemAriaLabelRecommended', "{0}. Recommended for this workspace", marketplaceLabel) + : marketplaceLabel; + } + + private getPluginEntryId(element: IPluginListEntry): string { + if (element.type === 'group-header' || element.type === 'search-header') { + return element.id; + } + if (element.type === 'marketplace-item') { + return `marketplace-${element.item.marketplaceReference.canonicalId}/${element.item.source}`; + } + if (element.type === 'remote-item') { + return element.item.itemKey ?? `remote-${element.item.groupKey ?? 'default'}-${element.item.uri.toString()}`; + } + return element.item.plugin.uri.toString(); + } + + private renderInstalledListActions(item: IInstalledPluginItem, row: HTMLElement, actions: HTMLElement, disposables: DisposableStore): void { + let renderedState = item.plugin.enablement.get(); + const switchElement = DOM.append(actions, $('button.plugin-enable-switch')) as HTMLButtonElement; + switchElement.type = 'button'; + switchElement.setAttribute('role', 'switch'); + DOM.append(switchElement, $('.plugin-enable-switch-thumb')); + disposables.add(DOM.addDisposableGenericMouseDownListener(switchElement, event => DOM.EventHelper.stop(event, true))); + const update = (state: ContributionEnablementState, blocked: boolean) => { + renderedState = state; + const checked = isContributionEnabled(state); + const workspaceScope = state === ContributionEnablementState.EnabledWorkspace || state === ContributionEnablementState.DisabledWorkspace; + const toggleLabel = checked + ? (workspaceScope ? localize('excludePluginWorkspaceAria', "Exclude {0} from Workspace", item.name) : localize('excludePluginProfileAria', "Exclude {0} from Profile", item.name)) + : (workspaceScope ? localize('includePluginWorkspaceAria', "Include {0} in Workspace", item.name) : localize('includePluginProfileAria', "Include {0} for Profile", item.name)); + switchElement.disabled = blocked; + switchElement.setAttribute('aria-checked', String(checked)); + switchElement.setAttribute('aria-label', blocked ? localize('pluginManagedByOrganizationAria', "{0} is managed by your organization", item.name) : toggleLabel); + switchElement.classList.toggle('checked', checked); + switchElement.title = blocked ? localize('pluginPolicyBlockedSwitch', "This plugin is managed by your organization.") : toggleLabel; + row.classList.toggle('disabled', !checked || blocked); + }; + disposables.add(autorun(reader => update(item.plugin.enablement.read(reader), item.plugin.policyBlocked?.read(reader) === true))); + disposables.add(DOM.addDisposableListener(switchElement, 'click', event => { + DOM.EventHelper.stop(event, true); + const nextState = getToggledPluginEnablementState(renderedState); + update(nextState, isPluginPolicyBlocked(item.plugin)); + this.agentPluginService.enablementModel.setEnabled(item.plugin.uri.toString(), nextState); + status(localize('pluginInclusionChanged', "{0}. {1}.", item.name, getPluginInclusionLabel(item.plugin))); + })); + + const more = disposables.add(new Button(actions, { + ...getButtonStyles({ buttonSecondaryBackground: undefined, buttonSecondaryBorder: undefined }), + secondary: true, + supportIcons: true, + ariaLabel: localize('pluginMoreActionsAria', "More actions for {0}", item.name), + })); + more.element.classList.add('plugin-card-icon-button'); + more.label = `$(${Codicon.ellipsis.id})`; + disposables.add(DOM.addDisposableGenericMouseDownListener(more.element, event => DOM.EventHelper.stop(event, true))); + disposables.add(more.onDidClick(event => { + DOM.EventHelper.stop(event, true); + this.showInstalledPluginActions(item, more.element); + })); + } + + private renderRemoteListActions(item: ICustomizationItem, actions: HTMLElement, disposables: DisposableStore): void { + if (!item.actions?.length) { + actions.style.display = 'none'; + return; + } + actions.style.display = ''; + const more = disposables.add(new Button(actions, { + ...getButtonStyles({ buttonSecondaryBackground: undefined, buttonSecondaryBorder: undefined }), + secondary: true, + supportIcons: true, + ariaLabel: localize('pluginMoreActionsAria', "More actions for {0}", item.name), + })); + more.element.classList.add('plugin-card-icon-button'); + more.label = `$(${Codicon.ellipsis.id})`; + disposables.add(DOM.addDisposableGenericMouseDownListener(more.element, event => DOM.EventHelper.stop(event, true))); + disposables.add(more.onDidClick(event => { + DOM.EventHelper.stop(event, true); + this.showRemotePluginActions(item, more.element); + })); + } + + private layoutPluginSectionLists(): void { + const delegate = new PluginItemDelegate(); + const content = this.sectionLayoutContainer; + if (!content) { + return; + } + const heights = layoutVirtualizedSections(content, this.sectionLists.map(section => ({ + container: section.container, + contentHeight: section.entries.reduce((height, entry) => height + delegate.getHeight(entry), 0), + minimumHeight: section.entries.length > 0 ? delegate.getHeight(section.entries[0]) : 0, + }))); + for (let index = 0; index < this.sectionLists.length; index++) { + const section = this.sectionLists[index]; + const height = heights[index]; + layoutVirtualizedSectionList(section.list, section.container, height, section.container.clientWidth || undefined); + } + } + + private schedulePluginSectionLayout(): void { + this.pendingSectionLayout.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.element), () => { + this.layoutPluginSectionLists(); + this.cardScrollable.scanDomNode(); + }); + } + private renderPluginHome(): void { if (this.browseMode || this.searchQuery.trim()) { return; } + this.captureSectionScrollPositions(); this.cardDisposables.clear(); + this.sectionLists = []; this.installedCreateButton = undefined; this.firstCardFocusElement = undefined; DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = this.createCardScrollContent(); + const content = this.createCardScrollContent('distributed-section-layout'); const installedPlugins = this.installedItems; const hasMarketplaceInstalledPlugins = this.pluginMarketplaceService.installedPlugins.get().length > 0; - this.renderDiscoverySnapshot(content); if (shouldLoadPluginMarketplaceSnapshot(this.visible, this.marketplaceSnapshot.state, this.isBrowseMarketplaceAvailable())) { void this.queryMarketplaceSnapshot(); } + this.renderDiscoverySnapshot(content); const installedList = this.renderCardSection( content, @@ -1285,11 +1588,8 @@ export class PluginListWidget extends Disposable { const empty = DOM.append(installedList, $('.plugin-inventory-empty')); empty.textContent = localize('noInstalledPlugins', "No plugins are installed."); } else { - for (const item of installedPlugins) { - this.appendInstalledPluginRow(installedList, item); - } + this.createPluginSectionList(installedList, localize('installedPluginsSection', "Installed"), installedPlugins.map(item => ({ type: 'plugin-item', item }))); } - this.cardListControllers.get(installedList)?.finalize(); const installedNames = new Set(this.installedItems.map(item => item.name.toLowerCase())); const remoteItems = this.remoteItems.filter(item => item.groupKey !== 'remote-client' && (!item.name || !installedNames.has(item.name.toLowerCase()))); @@ -1302,13 +1602,11 @@ export class PluginListWidget extends Disposable { remoteItems.length, ); remoteList.classList.add('plugin-inventory-list'); - for (const item of remoteItems) { - this.appendRemotePluginRow(remoteList, item); - } - this.cardListControllers.get(remoteList)?.finalize(); + this.createPluginSectionList(remoteList, localize('remotePluginsSection', "Remote session plugins"), remoteItems.map(item => ({ type: 'remote-item', item }))); } this.renderAvailablePlugins(content, this.getUninstalledMarketplaceItems(this.marketplaceSnapshot.items), true); + this.schedulePluginSectionLayout(); } private renderInstalledSectionActions(header: HTMLElement, hasInstalledPlugins: boolean): void { @@ -1346,15 +1644,16 @@ export class PluginListWidget extends Disposable { ); availableList.classList.add('plugin-inventory-list'); if (items.length === 0) { - const empty = DOM.append(availableList, $('.plugin-inventory-empty')); - empty.textContent = localize('noAvailablePlugins', "No marketplace plugins are available."); + if (this.marketplaceSnapshot.state === 'loading') { + renderVirtualizedSectionLoadingPlaceholder(availableList, localize('loadingMarketplace', "Loading marketplace..."), PLUGIN_MARKETPLACE_ITEM_HEIGHT); + } else { + const empty = DOM.append(availableList, $('.plugin-inventory-empty')); + empty.textContent = localize('noAvailablePlugins', "No marketplace plugins are available."); + } this.cardListControllers.get(availableList)?.finalize(); return; } - for (const item of items) { - this.appendMarketplacePluginRow(availableList, item); - } - this.cardListControllers.get(availableList)?.finalize(); + this.createPluginSectionList(availableList, title, items.map(item => ({ type: 'marketplace-item', item }))); } private renderAvailableSectionActions(header: HTMLElement): void { @@ -1387,7 +1686,7 @@ export class PluginListWidget extends Disposable { } } - private appendInstalledPluginRow(parent: HTMLElement, item: IInstalledPluginItem): void { + protected appendInstalledPluginRow(parent: HTMLElement, item: IInstalledPluginItem): void { const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.plugin-installed-item')); const primaryAction = this.addSurfaceActivation(row, localize('installedPluginRowAriaLabel', "{0}. {1}", item.name, getPluginInclusionLabel(item.plugin)), () => this._onDidSelectPlugin.fire(item)); @@ -1456,7 +1755,7 @@ export class PluginListWidget extends Disposable { return switchElement; } - private appendRemotePluginRow(parent: HTMLElement, item: ICustomizationItem): void { + protected appendRemotePluginRow(parent: HTMLElement, item: ICustomizationItem): void { const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.plugin-remote-item')); row.setAttribute('role', 'listitem'); row.setAttribute('aria-label', localize('pluginRemoteCardAria', "{0}. Remote plugin", item.name)); @@ -1501,7 +1800,7 @@ export class PluginListWidget extends Disposable { }); } - private appendMarketplacePluginRow(parent: HTMLElement, item: IMarketplacePluginItem): void { + protected appendMarketplacePluginRow(parent: HTMLElement, item: IMarketplacePluginItem): void { const row = DOM.append(parent, $('.plugin-list-item.plugin-home-row.plugin-marketplace-home-row')); const primaryAction = this.addSurfaceActivation(row, localize('marketplacePluginRowAriaLabel', "{0}. Available to install from {1}.", item.name, item.marketplace), () => this._onDidSelectPlugin.fire(item)); @@ -1526,40 +1825,10 @@ export class PluginListWidget extends Disposable { }); } - private appendMarketplacePluginCard(parent: HTMLElement, item: IMarketplacePluginItem, showRecommendedBadge = true): void { - const card = DOM.append(parent, $('.plugin-card.plugin-marketplace-card')); - const header = DOM.append(card, $('.plugin-card-header')); - const titleBlock = this.addSurfaceActivation(header, localize('marketplacePluginCardAriaLabel', "{0}. Available to install from {1}.", item.name, item.marketplace), () => this._onDidSelectPlugin.fire(item), 'plugin-card-title-block'); - const name = DOM.append(titleBlock, $('.plugin-card-title')); - name.textContent = item.name; - name.title = item.name; - const descriptionLine = DOM.append(titleBlock, $('.plugin-card-subtitle')); - descriptionLine.textContent = truncateToFirstLine(item.description || localize('pluginNoDescription', "No description provided.")); - const actions = DOM.append(header, $('.plugin-card-actions')); - const install = this.cardDisposables.add(new Button(actions, { ...defaultButtonStyles, ariaLabel: localize('installPluginAria', "Install {0}", item.name) })); - install.label = localize('install', "Install"); - this.cardDisposables.add(install.onDidClick(() => this.installMarketplacePlugin(item, install))); - if (showRecommendedBadge && this.pluginMarketplaceService.recommendedPlugins.get().has(getMarketplaceRecommendationKey(item))) { - const badges = DOM.append(card, $('.plugin-card-badges')); - this.appendCardBadge(badges, localize('recommendedBadge', "Recommended")); - } - this.cardListControllers.get(parent)?.addItem({ - row: card, - primaryAction: titleBlock, - label: item.name, - actions: [install.element], - }); - } - private rememberCardFocusElement(element: HTMLElement): void { this.firstCardFocusElement ??= element; } - private appendCardBadge(parent: HTMLElement, label: string): void { - const badge = DOM.append(parent, $('.inline-badge.plugin-card-badge')); - badge.textContent = label; - } - private renderDiscoverySnapshot(parent: HTMLElement): void { const marketplaceItems = this.getUninstalledMarketplaceItems(this.marketplaceSnapshot.items); if (marketplaceItems.length === 0) { @@ -1574,39 +1843,38 @@ export class PluginListWidget extends Disposable { ...recommended, ...marketplaceItems.filter(item => !recommendedKeys.has(getMarketplaceRecommendationKey(item))), ].slice(0, 3); - const section = DOM.append(parent, $('.plugin-card-section.plugin-discovery-section')); - const header = DOM.append(section, $('.plugin-card-section-header')); - const text = DOM.append(header, $('.plugin-card-section-text')); - const title = DOM.append(text, $('h3.plugin-card-section-title')); - title.textContent = localize('featuredPlugins', "Featured"); - const description = DOM.append(text, $('.plugin-card-section-description')); - description.textContent = localize('discoverMorePluginsDescription', "Curated plugins that add tools and expertise."); - const grid = DOM.append(section, $('.plugin-card-grid')); - this.cardListControllers.set(grid, this.cardDisposables.add(new CustomizationCardListController(grid, localize('featuredPlugins', "Featured")))); - for (const item of snapshotItems) { - this.appendMarketplacePluginCard(grid, item, false); - } - this.cardListControllers.get(grid)?.finalize(); + const grid = this.renderCardSection( + parent, + localize('featuredPlugins', "Featured"), + localize('discoverMorePluginsDescription', "Curated plugins that add tools and expertise."), + 'plugin-discovery-section', + ); + grid.classList.add('plugin-inventory-list'); + this.createPluginSectionList(grid, localize('featuredPlugins', "Featured"), snapshotItems.map(item => ({ type: 'marketplace-item', item })), false); } private renderDiscoveryError(parent: HTMLElement): void { - const section = DOM.append(parent, $('.plugin-card-section.plugin-discovery-section')); - const header = DOM.append(section, $('.plugin-card-section-header')); - const text = DOM.append(header, $('.plugin-card-section-text')); - const title = DOM.append(text, $('h3.plugin-card-section-title')); - title.textContent = localize('pluginDiscoveryUnavailable', "Available plugins could not be loaded"); - const description = DOM.append(text, $('.plugin-card-section-description')); - description.textContent = localize('pluginDiscoveryUnavailableDescription', "Check your connection, then try loading results from the configured marketplaces again."); - const retry = this.cardDisposables.add(new Button(header, { ...defaultButtonStyles, secondary: true, ariaLabel: localize('retryPluginDiscovery', "Retry Loading Plugins") })); - retry.label = localize('retry', "Retry"); - this.cardDisposables.add(retry.onDidClick(() => { - this.marketplaceSnapshot.reset(); - void this.queryMarketplaceSnapshot(); - })); + this.renderCardSection( + parent, + localize('pluginDiscoveryUnavailable', "Available plugins could not be loaded"), + localize('pluginDiscoveryUnavailableDescription', "Check your connection, then try loading results from the configured marketplaces again."), + 'plugin-discovery-section', + undefined, + header => { + const retry = this.cardDisposables.add(new Button(header, { ...defaultButtonStyles, secondary: true, ariaLabel: localize('retryPluginDiscovery', "Retry Loading Plugins") })); + retry.label = localize('retry', "Retry"); + this.cardDisposables.add(retry.onDidClick(() => { + this.marketplaceSnapshot.reset(); + void this.queryMarketplaceSnapshot(); + })); + }, + ); } private renderBrowseMarketplaceCards(): void { + this.captureSectionScrollPositions(); this.cardDisposables.clear(); + this.sectionLists = []; this.installedCreateButton = undefined; this.firstCardFocusElement = undefined; DOM.clearNode(this.cardContainer); @@ -1619,7 +1887,7 @@ export class PluginListWidget extends Disposable { } this.showCardSurface(); - const content = this.createCardScrollContent(); + const content = this.createCardScrollContent('distributed-section-layout'); const recommendedKeys = this.pluginMarketplaceService.recommendedPlugins.get(); const recommended = marketplaceItems.filter(item => recommendedKeys.has(getMarketplaceRecommendationKey(item))); const allPlugins = marketplaceItems.filter(item => !recommendedKeys.has(getMarketplaceRecommendationKey(item))); @@ -1630,10 +1898,8 @@ export class PluginListWidget extends Disposable { localize('recommendedGroupDescription', "Plugins recommended by workspace configuration."), 'plugin-marketplace-recommended-section' ); - for (const item of recommended) { - this.appendMarketplacePluginCard(recommendedGrid, item); - } - this.cardListControllers.get(recommendedGrid)?.finalize(); + recommendedGrid.classList.add('plugin-inventory-list'); + this.createPluginSectionList(recommendedGrid, localize('recommendedGroup', "Recommended for this workspace"), recommended.map(item => ({ type: 'marketplace-item', item }))); } const allGrid = this.renderCardSection( content, @@ -1641,10 +1907,11 @@ export class PluginListWidget extends Disposable { localize('allMarketplaceGroupDescription', "Plugins available from configured marketplaces."), 'plugin-marketplace-all-section' ); - for (const item of allPlugins) { - this.appendMarketplacePluginCard(allGrid, item); - } - this.cardListControllers.get(allGrid)?.finalize(); + allGrid.classList.add('plugin-inventory-list'); + this.createPluginSectionList(allGrid, localize('allMarketplaceGroup', "All plugins"), allPlugins.map(item => ({ type: 'marketplace-item', item }))); + this.layoutPluginSectionLists(); + this.cardScrollable.scanDomNode(); + this.schedulePluginSectionLayout(); } private getUninstalledMarketplaceItems(items: readonly IMarketplacePluginItem[] = this.marketplaceItems): IMarketplacePluginItem[] { @@ -1932,26 +2199,26 @@ export class PluginListWidget extends Disposable { return; } + this.captureSectionScrollPositions(); this.cardDisposables.clear(); + this.sectionLists = []; this.installedCreateButton = undefined; this.firstCardFocusElement = undefined; DOM.clearNode(this.cardContainer); this.showCardSurface(); - const content = this.createCardScrollContent('plugin-search-results'); + const content = this.createCardScrollContent('plugin-search-results', 'distributed-section-layout'); if (installedCount > 0) { const installedList = this.renderCardSection(content, localize('installedSearchHeader', "Installed"), undefined, 'installed-plugins-section', installedCount); installedList.classList.add('plugin-inventory-list'); - for (const item of this.installedItems) { - this.appendInstalledPluginRow(installedList, item); - } - for (const item of remoteItems) { - this.appendRemotePluginRow(installedList, item); - } - this.cardListControllers.get(installedList)?.finalize(); + this.createPluginSectionList(installedList, localize('installedSearchHeader', "Installed"), [ + ...this.installedItems.map(item => ({ type: 'plugin-item' as const, item })), + ...remoteItems.map(item => ({ type: 'remote-item' as const, item })), + ]); } if (this.marketplaceItems.length > 0) { this.renderAvailablePlugins(content, this.marketplaceItems, false, localize('availableSearchHeader', "Available to install"), undefined); } + this.schedulePluginSectionLayout(); this.list.splice(0, this.list.length, []); } @@ -2059,6 +2326,9 @@ export class PluginListWidget extends Disposable { layout(height: number, width: number): void { this.lastHeight = height; this.lastWidth = width; + if (!this.visible || this.element.parentElement?.style.display === 'none') { + return; + } this.element.style.height = `${height}px`; this.updateResponsiveLayout(width); @@ -2088,7 +2358,7 @@ export class PluginListWidget extends Disposable { this.cardScrollableNode.style.height = `${listHeight}px`; this.listContainer.style.height = `${listHeight}px`; this.list.layout(listHeight, width); - this.cardScrollable.scanDomNode(); + this.schedulePluginSectionLayout(); } focusSearch(): void { @@ -2097,7 +2367,16 @@ export class PluginListWidget extends Disposable { revealLastItem(): void { if (this.cardScrollableNode.style.display !== 'none') { - this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); + const reveal = () => { + const section = this.sectionLists.at(-1); + if (section?.entries.length) { + section.list.reveal(section.entries.length - 1); + } + this.cardScrollable.scanDomNode(); + this.cardScrollable.setScrollPosition({ scrollTop: this.cardContainer.scrollHeight }); + }; + reveal(); + this.revealLastItemScheduler.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.element), reveal); return; } if (this.list.length > 0) { @@ -2107,7 +2386,15 @@ export class PluginListWidget extends Disposable { focus(): void { if (this.cardScrollableNode.style.display !== 'none') { - this.firstCardFocusElement?.focus(); + if (this.firstCardFocusElement) { + this.firstCardFocusElement.focus(); + } else { + const section = this.sectionLists[0]; + if (section?.entries.length) { + section.list.setFocus([0]); + section.list.domFocus(); + } + } } else if (this.list.length > 0) { this.list.domFocus(); this.list.setFocus([0]); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts index 099bd6093598e5..feeb74e6dd2bef 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts @@ -8,7 +8,7 @@ import { IKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { HighlightedLabel } from '../../../../../base/browser/ui/highlightedlabel/highlightedLabel.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; -import { IListContextMenuEvent, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; +import { IListContextMenuEvent, IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { Checkbox, TriStateCheckbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; @@ -20,7 +20,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; import { IMatch, matchesContiguousSubString } from '../../../../../base/common/filters.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; -import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, derived, IObservable, IReader, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -31,6 +31,7 @@ import { IContextMenuService, IContextViewService } from '../../../../../platfor import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; +import { layoutVirtualizedSectionList, layoutVirtualizedSections, setupCollapsibleSection } from './customizationCardList.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { IExtensionManifestPropertiesService } from '../../../../services/extensions/common/extensionManifestPropertiesService.js'; @@ -47,18 +48,6 @@ export function isToolsTreeKeyboardTarget(target: HTMLElement, row: HTMLElement) return target === row; } -interface ITreeRow { - readonly kind: 'set' | 'tool'; - readonly rowId: string; - readonly toolSetId: string; - readonly element: HTMLElement; - readonly toggleNode: HTMLElement; - readonly group?: HTMLElement; - readonly children?: ITreeRow[]; - readonly parent?: ITreeRow; - readonly readOnly?: boolean; -} - interface IToolViewModel { readonly tool: IToolData; readonly nameMatches?: IMatch[]; @@ -72,8 +61,286 @@ interface IToolSetViewModel { /** When searching, sets are force-expanded to reveal matching tools regardless of user state. */ readonly forceExpanded: boolean; readonly readOnly: boolean; + /** Precomputed subtitle (own `detail`, or an extension description) shown under the set's name. */ + readonly detail?: string; +} + +//#region Virtualized tool rows + +/** A flattened row in a section's virtualized list: either a tool-set header or one of its member tools. */ +interface IToolsSetRowEntry { + readonly kind: 'set'; + readonly vm: IToolSetViewModel; +} + +interface IToolsToolRowEntry { + readonly kind: 'tool'; + readonly setVm: IToolSetViewModel; + readonly toolVm: IToolViewModel; +} + +type IToolsRowEntry = IToolsSetRowEntry | IToolsToolRowEntry; + +/** One section (Built-in / Connected / Extension) rendered as its own virtualized `WorkbenchList`. */ +interface IToolsSectionList { + readonly list: WorkbenchList; + /** Flattened rows currently spliced into {@link list}; reassigned (not mutated) on refresh. */ + entries: readonly IToolsRowEntry[]; + /** Stable set view models backing this section, used to recompute {@link entries} on expand/collapse. */ + readonly setVms: readonly IToolSetViewModel[]; + readonly container: HTMLElement; + readonly label: string; +} + +const TOOLS_SET_ROW_TEMPLATE_ID = 'toolsSetRow'; +const TOOLS_TOOL_ROW_TEMPLATE_ID = 'toolsToolRow'; +// Row heights derived from the fixed single-line label/subtext CSS plus each row kind's vertical padding. +const TOOLS_SET_ROW_PADDING = 16; // --vscode-spacing-size80 (8px) top + bottom +const TOOLS_TOOL_ROW_PADDING = 12; // --vscode-spacing-size60 (6px) top + bottom +const TOOLS_ROW_LABEL_HEIGHT = 18; +const TOOLS_ROW_SUBTEXT_HEIGHT = 14; +/** Caps a section's own scroll viewport height; sections with more content scroll internally. */ +const TOOLS_SECTION_MAX_HEIGHT = 320; + +function computeToolsRowHeight(entry: IToolsRowEntry): number { + if (entry.kind === 'set') { + return TOOLS_SET_ROW_PADDING + TOOLS_ROW_LABEL_HEIGHT + (entry.vm.detail ? TOOLS_ROW_SUBTEXT_HEIGHT : 0); + } + const description = entry.toolVm.tool.userDescription ?? entry.toolVm.tool.modelDescription; + return TOOLS_TOOL_ROW_PADDING + TOOLS_ROW_LABEL_HEIGHT + (description ? TOOLS_ROW_SUBTEXT_HEIGHT : 0); +} + +class ToolsRowDelegate implements IListVirtualDelegate { + getHeight(entry: IToolsRowEntry): number { + return computeToolsRowHeight(entry); + } + getTemplateId(entry: IToolsRowEntry): string { + return entry.kind === 'set' ? TOOLS_SET_ROW_TEMPLATE_ID : TOOLS_TOOL_ROW_TEMPLATE_ID; + } +} + +interface IToolsSetRowTemplateData { + readonly container: HTMLElement; + readonly checkbox: TriStateCheckbox; + readonly label: HighlightedLabel; + readonly subtext: HTMLElement; + readonly count: HTMLElement; + readonly alwaysAvailable: HTMLElement; + readonly moreButton: HTMLButtonElement; + readonly chevron: HTMLElement; + readonly templateDisposables: DisposableStore; + readonly elementDisposables: DisposableStore; + currentIndex: number; +} + +/** Renders a tool-set header row: checkbox/tri-state, name + detail, enabled count, more actions, chevron. */ +class ToolsSetRowRenderer implements IListRenderer { + readonly templateId = TOOLS_SET_ROW_TEMPLATE_ID; + private readonly _templates = new Set(); + private _focusedIndex = -1; + + constructor( + private readonly _sessionType: string, + private readonly _enablementService: IAgentHostToolSetEnablementService, + private readonly _isExpanded: (vm: IToolSetViewModel, reader: IReader) => boolean, + private readonly _toggleExpand: (setId: string) => void, + private readonly _resolveExtension: (ts: IToolSet) => IExtension | undefined, + private readonly _showExtensionMenu: (anchor: HTMLElement, extension: IExtension) => void, + ) { } + + renderTemplate(container: HTMLElement): IToolsSetRowTemplateData { + container.classList.add('tools-list-setrow'); + const templateDisposables = new DisposableStore(); + + const checkbox = templateDisposables.add(new TriStateCheckbox('', false, defaultCheckboxStyles)); + checkbox.domNode.tabIndex = -1; + container.appendChild(checkbox.domNode); + templateDisposables.add(DOM.addDisposableGenericMouseDownListener(checkbox.domNode, event => DOM.EventHelper.stop(event, true))); + + const main = DOM.append(container, $('.tools-list-row-main')); + const text = DOM.append(main, $('.tools-list-row-text')); + const labelEl = DOM.append(text, $('span.tools-list-row-label')); + const label = templateDisposables.add(new HighlightedLabel(labelEl)); + const subtext = DOM.append(text, $('span.tools-list-row-subtext')); + + const count = DOM.append(container, $('span.tools-list-row-count')); + const alwaysAvailable = DOM.append(container, $('span.tools-list-always-available')); + alwaysAvailable.textContent = localize('toolsAlwaysAvailable', "Always Available"); + + const moreButton = DOM.append(container, $('button.tools-list-more-action')) as HTMLButtonElement; + moreButton.type = 'button'; + moreButton.tabIndex = -1; + moreButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.ellipsis)); + templateDisposables.add(DOM.addDisposableGenericMouseDownListener(moreButton, event => DOM.EventHelper.stop(event, true))); + + const chevron = DOM.append(container, $('a.tools-list-chevron.codicon')) as HTMLAnchorElement; + chevron.setAttribute('aria-hidden', 'true'); + + const template = { container, checkbox, label, subtext, count, alwaysAvailable, moreButton, chevron, templateDisposables, elementDisposables: templateDisposables.add(new DisposableStore()), currentIndex: -1 }; + this._templates.add(template); + return template; + } + + renderElement(entry: IToolsSetRowEntry, index: number, data: IToolsSetRowTemplateData): void { + data.elementDisposables.clear(); + data.currentIndex = index; + data.container.removeAttribute('aria-selected'); + const vm = entry.vm; + const ts = vm.toolSet; + const setName = ts.description ?? ts.referenceName; + + data.label.set(setName, vm.nameMatches); + data.subtext.style.display = vm.detail ? '' : 'none'; + data.subtext.textContent = vm.detail ?? ''; + data.alwaysAvailable.style.display = vm.readOnly ? '' : 'none'; + data.checkbox.domNode.style.display = vm.readOnly ? 'none' : ''; + + if (!vm.readOnly) { + data.checkbox.setTitle(localize('toolsSetCheckbox', "Enable {0}", setName)); + data.elementDisposables.add(data.checkbox.onChange(() => { + this._enablementService.setToolSetEnabled(this._sessionType, ts.id, vm.allToolIds, data.checkbox.checked === true); + })); + } + + // Tri-state, enabled count and aria-checked all follow the same enablement observable. + data.elementDisposables.add(autorun(reader => { + const state = this._enablementService.observe(this._sessionType).read(reader); + const triState = getToolSetTriState(state, ts.id, vm.allToolIds); + if (!vm.readOnly) { + data.checkbox.checked = triState; + data.container.setAttribute('aria-checked', triState === 'mixed' ? 'mixed' : String(triState)); + } else { + data.container.removeAttribute('aria-checked'); + } + const enabledCount = vm.allToolIds.reduce((n, id) => n + (isToolEnabledInSet(state, ts.id, id) ? 1 : 0), 0); + data.count.textContent = `${enabledCount}/${vm.allToolIds.length}`; + data.count.setAttribute('aria-label', localize('toolsRowEnabledOfTotal', "{0} of {1} tools enabled", enabledCount, vm.allToolIds.length)); + })); + + data.elementDisposables.add(autorun(reader => { + const expanded = this._isExpanded(vm, reader); + data.chevron.classList.toggle('codicon-chevron-down-compact', expanded); + data.chevron.classList.toggle('codicon-chevron-right-compact', !expanded); + data.container.setAttribute('aria-expanded', String(expanded)); + })); + + const extension = this._resolveExtension(ts); + data.moreButton.style.display = extension ? '' : 'none'; + data.moreButton.tabIndex = extension && index === this._focusedIndex ? 0 : -1; + if (extension) { + const moreLabel = localize('toolsSetMoreActions', "More actions for {0}", setName); + data.moreButton.setAttribute('aria-label', moreLabel); + data.moreButton.title = moreLabel; + data.elementDisposables.add(DOM.addDisposableListener(data.moreButton, 'click', e => { + DOM.EventHelper.stop(e, true); + this._showExtensionMenu(data.moreButton, extension); + })); + } + + // Clicking the row body (not the checkbox/more-actions button) toggles expand/collapse. + data.elementDisposables.add(DOM.addDisposableListener(data.container, 'click', e => { + if (data.checkbox.domNode.contains(e.target as Node) || data.moreButton.contains(e.target as Node)) { + return; + } + this._toggleExpand(ts.id); + })); + } + + setFocusedIndex(index: number): void { + this._focusedIndex = index; + for (const template of this._templates) { + template.moreButton.tabIndex = template.moreButton.style.display !== 'none' && template.currentIndex === index ? 0 : -1; + } + } + + disposeTemplate(data: IToolsSetRowTemplateData): void { + this._templates.delete(data); + data.templateDisposables.dispose(); + } +} + +interface IToolsToolRowTemplateData { + readonly container: HTMLElement; + readonly checkbox: Checkbox; + readonly label: HighlightedLabel; + readonly subtext: HTMLElement; + readonly alwaysAvailable: HTMLElement; + readonly templateDisposables: DisposableStore; + readonly elementDisposables: DisposableStore; +} + +/** Renders a member-tool row nested (visually, via padding) under its tool-set header. */ +class ToolsToolRowRenderer implements IListRenderer { + readonly templateId = TOOLS_TOOL_ROW_TEMPLATE_ID; + + constructor( + private readonly _sessionType: string, + private readonly _enablementService: IAgentHostToolSetEnablementService, + ) { } + + renderTemplate(container: HTMLElement): IToolsToolRowTemplateData { + container.classList.add('tools-list-toolrow'); + const templateDisposables = new DisposableStore(); + + const checkbox = templateDisposables.add(new Checkbox('', false, defaultCheckboxStyles)); + checkbox.domNode.tabIndex = -1; + container.appendChild(checkbox.domNode); + templateDisposables.add(DOM.addDisposableGenericMouseDownListener(checkbox.domNode, event => DOM.EventHelper.stop(event, true))); + + const text = DOM.append(container, $('.tools-list-row-text')); + const labelEl = DOM.append(text, $('span.tools-list-row-label')); + const label = templateDisposables.add(new HighlightedLabel(labelEl)); + const subtext = DOM.append(text, $('span.tools-list-row-subtext')); + + const alwaysAvailable = DOM.append(container, $('span.tools-list-always-available')); + alwaysAvailable.textContent = localize('toolsAlwaysAvailable', "Always Available"); + + return { container, checkbox, label, subtext, alwaysAvailable, templateDisposables, elementDisposables: templateDisposables.add(new DisposableStore()) }; + } + + renderElement(entry: IToolsToolRowEntry, _index: number, data: IToolsToolRowTemplateData): void { + data.elementDisposables.clear(); + data.container.removeAttribute('aria-selected'); + const { setVm, toolVm } = entry; + const tool = toolVm.tool; + const toolName = tool.displayName ?? tool.id; + + data.container.classList.toggle('readonly', setVm.readOnly); + data.label.set(toolName, toolVm.nameMatches); + const description = tool.userDescription ?? tool.modelDescription; + data.subtext.style.display = description ? '' : 'none'; + data.subtext.textContent = description ?? ''; + data.alwaysAvailable.style.display = setVm.readOnly ? '' : 'none'; + data.checkbox.domNode.style.display = setVm.readOnly ? 'none' : ''; + + if (!setVm.readOnly) { + data.checkbox.setTitle(localize('toolsToolCheckbox', "Enable {0}", toolName)); + data.elementDisposables.add(data.checkbox.onChange(() => { + this._enablementService.setToolEnabled(this._sessionType, setVm.toolSet.id, tool.id, data.checkbox.checked); + })); + data.elementDisposables.add(autorun(reader => { + const enabled = isToolEnabledInSet(this._enablementService.observe(this._sessionType).read(reader), setVm.toolSet.id, tool.id); + data.checkbox.checked = enabled; + data.container.setAttribute('aria-checked', String(enabled)); + })); + data.elementDisposables.add(DOM.addDisposableListener(data.container, 'click', e => { + if (data.checkbox.domNode.contains(e.target as Node)) { + return; + } + this._enablementService.setToolEnabled(this._sessionType, setVm.toolSet.id, tool.id, !data.checkbox.checked); + })); + } else { + data.container.removeAttribute('aria-checked'); + } + } + + disposeTemplate(data: IToolsToolRowTemplateData): void { + data.templateDisposables.dispose(); + } } +//#endregion + /** * Marketplace search used when browsing for tool-contributing extensions. The marketplace cannot * be filtered server-side by contributed feature, so this is a text query. @@ -143,6 +410,7 @@ export class ToolsListWidget extends Disposable { readonly onDidSelectExtension = this._onDidSelectExtension.event; private readonly _rowStore = this._register(new DisposableStore()); + private readonly _pendingSectionLayout = this._register(new MutableDisposable()); private readonly _searchQuery = observableValue('toolsSearchQuery', ''); private readonly _expanded = observableValue>('toolsExpanded', new Set()); private readonly _delayedSearch = this._register(new Delayer(200)); @@ -164,9 +432,9 @@ export class ToolsListWidget extends Disposable { private _lastHeight = 0; private _lastWidth = 0; - private _activeRowId: string | undefined; - private _rows: ITreeRow[] = []; - private readonly _rowByElement = new Map(); + private _sectionLists: IToolsSectionList[] = []; + private _collapsedSections: Set | undefined = new Set(); + private readonly _sectionScrollPositions = new Map(); /** Read-only tool sets injected for the current session type (e.g. the Copilot CLI built-ins). */ private readonly _staticReadOnlySets: readonly IToolSet[]; @@ -194,16 +462,7 @@ export class ToolsListWidget extends Disposable { // Wrap the tree in a DomScrollableElement for an overlay scrollbar (not the native one). this._treeContainer = $('.tools-list-tree'); - this._treeContainer.setAttribute('role', 'tree'); - this._treeContainer.setAttribute('aria-label', localize('toolsTreeAria', "Tool groups")); - // Tree-style keyboard navigation with a roving tabIndex, so the tree is a single tab stop. - this._register(DOM.addStandardDisposableListener(this._treeContainer, DOM.EventType.KEY_DOWN, e => this._onTreeKeyDown(e))); - this._register(DOM.addDisposableListener(this._treeContainer, DOM.EventType.FOCUS_IN, e => { - const row = this._rowFromTarget(e.target as HTMLElement); - if (row) { - this._setRovingRow(row); - } - })); + this._treeContainer.classList.add('distributed-section-layout'); this._treeScrollable = this._register(new DomScrollableElement(this._treeContainer, { horizontal: ScrollbarVisibility.Hidden, vertical: ScrollbarVisibility.Auto, @@ -221,6 +480,12 @@ export class ToolsListWidget extends Disposable { this._render(viewModel.read(reader)); })); + // Expand/collapse never rebuilds the DOM; it only re-splices the affected section's rows in place. + this._register(autorun(reader => { + this._expanded.read(reader); + this._refreshAllSectionEntries(); + })); + this._register(autorun(reader => { // Badge counts enabled individual tools across all visible sets, ignoring the search filter. const count = countEnabledCustomizationTools(this._toolsService.toolSets.read(reader), this._readState(reader), reader); @@ -397,16 +662,20 @@ export class ToolsListWidget extends Disposable { visibleTools, nameMatches, forceExpanded: query !== '', - readOnly: ts.id === 'copilot-cli' + readOnly: ts.id === 'copilot-cli', + detail: this._resolveSetDetail(ts) }; } layout(height: number, width: number): void { this._lastHeight = height; this._lastWidth = width; + if (this.element.parentElement?.style.display === 'none') { + return; + } this.element.classList.toggle('narrow-layout', width < 500); this._searchInput.layout(); - this._treeScrollable.scanDomNode(); + this._scheduleSectionListLayout(); const galleryOffset = this._galleryContainer.getBoundingClientRect().top - this.element.getBoundingClientRect().top; this._galleryList.layout(Math.max(0, height - galleryOffset), width); @@ -537,11 +806,15 @@ export class ToolsListWidget extends Disposable { } private _render(model: readonly IToolSetViewModel[]): void { - // A live update (search/tool-set change) rebuilds rows; keep keyboard focus in the tree if it was there. - const hadFocus = DOM.isAncestor(this._treeContainer.ownerDocument.activeElement, this._treeContainer); + // A live update (search/tool-set change) rebuilds sections; keep keyboard focus if it was in the tree. + const focusedSection = this._sectionLists.find(s => DOM.isAncestor(this._treeContainer.ownerDocument.activeElement, s.container)); + const focusedRowId = focusedSection ? this._currentFocusedRowId(focusedSection) : undefined; + for (const section of this._sectionLists) { + this._sectionScrollPositions.set(section.label, section.list.scrollTop); + } + this._rowStore.clear(); - this._rows = []; - this._rowByElement.clear(); + this._sectionLists = []; DOM.clearNode(this._treeContainer); const query = this._searchQuery.get().trim(); @@ -589,8 +862,11 @@ export class ToolsListWidget extends Disposable { this._rowStore.add(browseButton.onDidClick(() => this._setBrowseMode(true))); } : undefined, ); - this._initRovingTabIndex(hadFocus); - this._treeScrollable.scanDomNode(); + + this._scheduleSectionListLayout(); + if (focusedRowId) { + this._restoreFocus(focusedRowId); + } } private _renderToolSection( @@ -614,340 +890,241 @@ export class ToolsListWidget extends Disposable { DOM.append(text, $('p.tools-inventory-section-description')).textContent = description; renderActions?.(header); - const inventory = DOM.append(section, $('.tools-inventory-list')); - inventory.setAttribute('role', 'group'); - inventory.setAttribute('aria-label', title); + let inventory: HTMLElement; if (model.length === 0) { + inventory = DOM.append(section, $('.tools-inventory-list')); DOM.append(inventory, $('.plugin-inventory-empty')).textContent = emptyMessage; - return; - } - for (const vm of model) { - const setRow = this._renderToolSet(inventory, vm); - this._addRow(setRow); - for (const child of setRow.children!) { - this._addRow(child); - } + } else { + inventory = this._createToolsSectionList(section, title, model).container; } + const collapsedSections = this._collapsedSections ??= new Set(); + setupCollapsibleSection( + headingRow, + inventory, + title, + this._rowStore, + collapsedSections.has(title), + collapsed => { + if (collapsed) { + collapsedSections.add(title); + } else { + collapsedSections.delete(title); + } + this._scheduleSectionListLayout(); + }, + ); } - private _addRow(row: ITreeRow): void { - this._rows.push(row); - this._rowByElement.set(row.element, row); - } - - private _renderToolSet(container: HTMLElement, vm: IToolSetViewModel): ITreeRow { - const ts = vm.toolSet; - const row = DOM.append(container, $('.tools-list-setrow')); - // Tree item with a roving tabIndex: navigated with arrows, toggled with Space; not a Tab stop. - row.setAttribute('role', 'treeitem'); - row.setAttribute('aria-level', '1'); - row.tabIndex = -1; - - const setName = ts.description ?? ts.referenceName; - const toggleExpand = () => this._toggleCollapsed(ts.id); - - let checkbox: TriStateCheckbox | undefined; - if (!vm.readOnly) { - checkbox = this._rowStore.add(new TriStateCheckbox( - localize('toolsSetCheckbox', "Enable {0}", setName), - getToolSetTriState(this._currentState(), ts.id, vm.allToolIds), - defaultCheckboxStyles, - )); - checkbox.domNode.tabIndex = -1; - row.prepend(checkbox.domNode); - this._rowStore.add(checkbox.onChange(() => { - const enabled = checkbox!.checked === true; - this._enablementService.setToolSetEnabled(this._sessionType, ts.id, vm.allToolIds, enabled); - })); - } - - const main = DOM.append(row, $('.tools-list-row-main')); - const text = DOM.append(main, $('.tools-list-row-text')); - const label = DOM.append(text, $('span.tools-list-row-label')); - const labelHighlight = this._rowStore.add(new HighlightedLabel(label)); - labelHighlight.set(setName, vm.nameMatches); - const detail = this._resolveSetDetail(ts); - if (detail) { - DOM.append(text, $('span.tools-list-row-subtext')).textContent = detail; - } - - const count = DOM.append(row, $('span.tools-list-row-count')); - if (vm.readOnly) { - DOM.append(row, $('span.tools-list-always-available')).textContent = localize('toolsAlwaysAvailable', "Always Available"); - } - const extension = this._resolveExtensionForToolSet(ts); - let moreButton: HTMLButtonElement | undefined; - if (extension) { - const moreLabel = localize('toolsSetMoreActions', "More actions for {0}", setName); - moreButton = DOM.append(row, $('button.tools-list-more-action')) as HTMLButtonElement; - moreButton.type = 'button'; - moreButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.ellipsis)); - moreButton.setAttribute('aria-label', moreLabel); - moreButton.title = moreLabel; - this._rowStore.add(DOM.addDisposableListener(moreButton, 'click', e => { - DOM.EventHelper.stop(e, true); - this._showExtensionContextMenu(moreButton!, extension); - })); - } - - // Decorative chevron: expand state is on the row (aria-expanded); toggled by row click or arrows. - const chevron = DOM.append(row, $('a.tools-list-chevron.codicon')) as HTMLAnchorElement; - chevron.setAttribute('aria-hidden', 'true'); - - this._rowStore.add(DOM.addDisposableListener(row, 'click', e => { - if (checkbox?.domNode.contains(e.target as Node) || moreButton?.contains(e.target as Node)) { - return; - } - row.focus(); - toggleExpand(); - })); - - const group = DOM.append(container, $('.tools-list-children')); - group.id = `tools-group-${ts.id}`; - group.setAttribute('role', 'group'); - group.setAttribute('aria-label', setName); - // The child group is a DOM sibling (flat flex layout), so associate it with the parent item via aria-owns. - row.setAttribute('aria-owns', group.id); - - const setRow: ITreeRow = { - kind: 'set', - rowId: `set:${ts.id}`, - toolSetId: ts.id, - element: row, - toggleNode: checkbox?.domNode ?? row, - group, - children: [], - readOnly: vm.readOnly, + /** Creates one virtualized `WorkbenchList` for a section, flattening its sets/tools into rows. */ + private _createToolsSectionList(sectionEl: HTMLElement, label: string, setVms: readonly IToolSetViewModel[]): IToolsSectionList { + const listContainer = DOM.append(sectionEl, $('.tools-inventory-list')); + + const setRenderer = new ToolsSetRowRenderer( + this._sessionType, + this._enablementService, + (vm, reader) => vm.forceExpanded || this._expanded.read(reader).has(vm.toolSet.id), + setId => this._toggleCollapsed(setId), + ts => this._resolveExtensionForToolSet(ts), + (anchor, extension) => this._showExtensionContextMenu(anchor, extension), + ); + const list = this._rowStore.add(this._instantiationService.createInstance( + WorkbenchList, + 'ToolsSectionList', + listContainer, + new ToolsRowDelegate(), + [ + setRenderer, + new ToolsToolRowRenderer(this._sessionType, this._enablementService), + ], + { + multipleSelectionSupport: false, + horizontalScrolling: false, + accessibilityProvider: { + getWidgetAriaLabel: () => label, + getWidgetRole: () => 'tree', + getRole: () => 'treeitem', + getAriaLevel: (entry: IToolsRowEntry) => entry.kind === 'set' ? 1 : 2, + // Rows carry no explicit aria-label, same as the original DOM tree: assistive tech + // derives the accessible name from each row's own label/subtext/count text content. + getAriaLabel: () => null, + }, + identityProvider: { getId: (entry: IToolsRowEntry) => this._entryRowId(entry) }, + }, + )) as WorkbenchList; + + const section: IToolsSectionList = { + list, + entries: this._computeSectionEntries(setVms), + setVms, + container: listContainer, + label, }; - for (const tool of vm.visibleTools) { - setRow.children!.push(this._renderTool(group, setRow, vm, tool)); + if (section.entries.length > 0) { + listContainer.style.height = `${computeToolsRowHeight(section.entries[0])}px`; } - - // Tri-state and count reflect enablement; update in place so a toggle never rebuilds the row. - this._rowStore.add(autorun(reader => { - const state = this._readState(reader); - const triState = getToolSetTriState(state, ts.id, vm.allToolIds); - if (checkbox) { - checkbox.checked = triState; - this._updateRowAriaChecked(row, triState); - } else { - row.removeAttribute('aria-checked'); + list.splice(0, list.length, section.entries as IToolsRowEntry[]); + list.scrollTop = this._sectionScrollPositions.get(label) ?? 0; + this._rowStore.add(list.onDidChangeSelection(event => { + if (event.indexes.length > 0) { + list.setSelection([]); } - const enabledCount = vm.allToolIds.reduce((n, id) => n + (isToolEnabledInSet(state, ts.id, id) ? 1 : 0), 0); - count.textContent = `${enabledCount}/${vm.allToolIds.length}`; - count.setAttribute('aria-label', localize('toolsRowEnabledOfTotal', "{0} of {1} tools enabled", enabledCount, vm.allToolIds.length)); })); - - // Expand/collapse toggles child visibility in place (no rebuild) so row focus is kept. - this._rowStore.add(autorun(reader => { - const expanded = vm.forceExpanded || this._expanded.read(reader).has(ts.id); - group.style.display = expanded ? '' : 'none'; - chevron.classList.toggle('codicon-chevron-down-compact', expanded); - chevron.classList.toggle('codicon-chevron-right-compact', !expanded); - row.setAttribute('aria-expanded', String(expanded)); - this._treeScrollable.scanDomNode(); + this._rowStore.add(list.onDidChangeFocus(event => setRenderer.setFocusedIndex(event.indexes[0] ?? -1))); + + // Captured (via a capture-phase listener on an ancestor of the list, so it runs strictly before + // the list's own bubble-phase key handler) so Up/Down at a section's edge can be told apart from + // a normal in-section move that merely lands on the edge. + let focusBeforeKeyDown: number | undefined; + this._rowStore.add(DOM.addStandardDisposableListener(listContainer, DOM.EventType.KEY_DOWN, () => { + focusBeforeKeyDown = list.getFocus()[0]; + }, true)); + // Registered after `createInstance` above, so on the list's own DOM node this listener runs + // after the list's internal keyboard controller (same-node listeners fire in registration order). + // This lets Up/Down/Enter/PageUp/PageDown/Escape/Ctrl+A keep working exactly as List implements + // them; only the keys List does not handle (Space/Left/Right/Home/End) are handled here. + this._rowStore.add(DOM.addStandardDisposableListener(list.getHTMLElement(), DOM.EventType.KEY_DOWN, e => { + this._onSectionKeyDown(section, e, () => focusBeforeKeyDown); })); - return setRow; + this._sectionLists.push(section); + return section; } - private _renderTool(group: HTMLElement, parent: ITreeRow, vm: IToolSetViewModel, toolVm: IToolViewModel): ITreeRow { - const tool = toolVm.tool; - const enabled = isToolEnabledInSet(this._currentState(), vm.toolSet.id, tool.id); - const toolName = tool.displayName ?? tool.id; - - const row = DOM.append(group, $('.tools-list-toolrow')); - row.classList.toggle('readonly', vm.readOnly); - // Tree item at level 2; read-only tools stay navigable (only the checkbox is disabled). - row.setAttribute('role', 'treeitem'); - row.setAttribute('aria-level', '2'); - row.tabIndex = -1; - - let checkbox: Checkbox | undefined; - if (!vm.readOnly) { - checkbox = this._rowStore.add(new Checkbox( - localize('toolsToolCheckbox', "Enable {0}", toolName), - enabled, - defaultCheckboxStyles, - )); - checkbox.domNode.tabIndex = -1; - row.prepend(checkbox.domNode); - this._updateRowAriaChecked(row, enabled); - this._rowStore.add(checkbox.onChange(() => { - this._enablementService.setToolEnabled(this._sessionType, vm.toolSet.id, tool.id, checkbox!.checked); - })); - - this._rowStore.add(DOM.addDisposableListener(row, 'click', e => { - if (checkbox!.domNode.contains(e.target as Node)) { - return; + /** Flattens a section's tool sets into rows, expanding each set's tools when the set is expanded. */ + private _computeSectionEntries(setVms: readonly IToolSetViewModel[]): IToolsRowEntry[] { + const entries: IToolsRowEntry[] = []; + for (const vm of setVms) { + entries.push({ kind: 'set', vm }); + if (this._isRowExpanded(vm)) { + for (const toolVm of vm.visibleTools) { + entries.push({ kind: 'tool', setVm: vm, toolVm }); } - row.focus(); - this._enablementService.setToolEnabled(this._sessionType, vm.toolSet.id, tool.id, !checkbox!.checked); - })); - - // Keep the checkbox and the treeitem's aria-checked in sync (e.g. when the parent set is toggled). - this._rowStore.add(autorun(reader => { - const toolEnabled = isToolEnabledInSet(this._readState(reader), vm.toolSet.id, tool.id); - checkbox!.checked = toolEnabled; - this._updateRowAriaChecked(row, toolEnabled); - })); - } - - const text = DOM.append(row, $('.tools-list-row-text')); - const label = DOM.append(text, $('span.tools-list-row-label')); - const labelHighlight = this._rowStore.add(new HighlightedLabel(label)); - labelHighlight.set(toolName, toolVm.nameMatches); - const description = tool.userDescription ?? tool.modelDescription; - if (description) { - const subtext = DOM.append(text, $('span.tools-list-row-subtext')); - subtext.textContent = description; - } - if (vm.readOnly) { - DOM.append(row, $('span.tools-list-always-available')).textContent = localize('toolsAlwaysAvailable', "Always Available"); + } } - - return { - kind: 'tool', - rowId: `tool:${vm.toolSet.id}:${tool.id}`, - toolSetId: vm.toolSet.id, - element: row, - toggleNode: checkbox?.domNode ?? row, - parent, - readOnly: vm.readOnly, - }; + return entries; } - /** - * Subtitle for a tool-set row: the set's own `detail`, or for extension sets the extension's - * description (falling back to a generic "contributed by" label). - */ - private _resolveSetDetail(ts: IToolSet): string | undefined { - if (ts.detail) { - return ts.detail; - } - if (ts.source.type !== 'extension') { - return undefined; - } - const source = ts.source; - const extension = this._extensionsWorkbenchService.local.find(e => ExtensionIdentifier.equals(e.identifier.id, source.extensionId)); - return extension?.description || localize('toolsSetExtensionDetail', "Tools contributed by {0}", source.label); + private _isRowExpanded(vm: IToolSetViewModel): boolean { + return vm.forceExpanded || this._expanded.get().has(vm.toolSet.id); } - /** Mirror a row's enablement onto its `treeitem` so assistive tech announces it while navigating. */ - private _updateRowAriaChecked(element: HTMLElement, state: boolean | 'mixed'): void { - element.setAttribute('aria-checked', state === 'mixed' ? 'mixed' : String(state)); + private _entryRowId(entry: IToolsRowEntry): string { + return entry.kind === 'set' ? `set:${entry.vm.toolSet.id}` : `tool:${entry.setVm.toolSet.id}:${entry.toolVm.tool.id}`; } - private _toggleCollapsed(toolSetId: string): void { - const next = new Set(this._expanded.get()); - if (next.has(toolSetId)) { - next.delete(toolSetId); - } else { - next.add(toolSetId); - } - this._expanded.set(next, undefined); + private _currentFocusedRowId(section: IToolsSectionList): string | undefined { + const index = section.list.getFocus()[0]; + const entry = index !== undefined ? section.entries[index] : undefined; + return entry ? this._entryRowId(entry) : undefined; } - private _setExpanded(toolSetId: string, expanded: boolean): void { - const next = new Set(this._expanded.get()); - if (expanded === next.has(toolSetId)) { - return; - } - if (expanded) { - next.add(toolSetId); - } else { - next.delete(toolSetId); + /** Re-splices every section's rows in place (no DOM teardown) after an `_expanded` state change. */ + private _refreshAllSectionEntries(): void { + for (const section of this._sectionLists) { + this._refreshSectionEntries(section); } - this._expanded.set(next, undefined); } - // --- Tree keyboard navigation --- - - private _isExpanded(setRow: ITreeRow): boolean { - return setRow.group!.style.display !== 'none'; + private _refreshSectionEntries(section: IToolsSectionList): void { + const focusedRowId = this._currentFocusedRowId(section); + const nextEntries = this._computeSectionEntries(section.setVms); + section.entries = nextEntries; + section.list.splice(0, section.list.length, nextEntries as IToolsRowEntry[]); + if (focusedRowId) { + const index = nextEntries.findIndex(e => this._entryRowId(e) === focusedRowId); + if (index !== -1) { + section.list.setFocus([index]); + section.list.domFocus(); + } + } + this._scheduleSectionListLayout(); } - /** Rows the user can currently land on: all set rows plus tool rows inside expanded sets, in tree order. */ - private _visibleRows(): ITreeRow[] { - return this._rows.filter(r => r.kind === 'set' || this._isExpanded(r.parent!)); + /** Restore keyboard focus to a row by its stable id after a full re-render, falling back to the first row. */ + private _restoreFocus(rowId: string): void { + for (const section of this._sectionLists) { + if (section.container.hidden) { + continue; + } + const index = section.entries.findIndex(e => this._entryRowId(e) === rowId); + if (index !== -1) { + section.list.setFocus([index]); + section.list.reveal(index); + section.list.domFocus(); + return; + } + } + this._focusFirstOverall(); } - /** Keep a single roving `tabIndex=0` on the given row so the tree is one tab stop. */ - private _setRovingRow(row: ITreeRow): void { - for (const r of this._rows) { - r.element.tabIndex = r === row ? 0 : -1; + private _layoutSectionLists(): void { + const heights = layoutVirtualizedSections(this._treeContainer, this._sectionLists.map(section => ({ + container: section.container, + contentHeight: section.entries.reduce((sum, entry) => sum + computeToolsRowHeight(entry), 0), + minimumHeight: section.entries.length > 0 ? computeToolsRowHeight(section.entries[0]) : 0, + }))); + for (let index = 0; index < this._sectionLists.length; index++) { + this._layoutSection(this._sectionLists[index], heights[index]); } - this._activeRowId = row.rowId; } - private _focusRow(row: ITreeRow): void { - this._setRovingRow(row); - row.element.focus(); + private _scheduleSectionListLayout(): void { + this._pendingSectionLayout.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.element), () => { + this._layoutSectionLists(); + this._treeScrollable.scanDomNode(); + }); } - /** Resolve the row owning a focus/keyboard target by walking up to a known row element. */ - private _rowFromTarget(target: HTMLElement | null): ITreeRow | undefined { - for (let el = target; el && el !== this._treeContainer; el = el.parentElement) { - const row = this._rowByElement.get(el); - if (row) { - return row; - } - } - return undefined; + private _layoutSection(section: IToolsSectionList, allocatedHeight?: number): void { + const contentHeight = section.entries.reduce((sum, e) => sum + computeToolsRowHeight(e), 0); + const height = allocatedHeight ?? Math.min(contentHeight, TOOLS_SECTION_MAX_HEIGHT); + layoutVirtualizedSectionList(section.list, section.container, height, section.container.clientWidth || this._lastWidth || undefined); } - /** After a (re)render, restore the roving tabIndex to the previously active row, else the first row. */ - private _initRovingTabIndex(refocus = false): void { - let active = this._activeRowId ? this._rows.find(r => r.rowId === this._activeRowId) : undefined; - if (!active || (active.kind === 'tool' && !this._isExpanded(active.parent!))) { - active = this._visibleRows()[0]; - } - for (const r of this._rows) { - r.element.tabIndex = r === active ? 0 : -1; - } - this._activeRowId = active?.rowId; - if (refocus && active) { - active.element.focus(); - } - } + // --- Tree keyboard navigation (supplemental to WorkbenchList's own Up/Down/Enter/PageUp/PageDown/Escape) --- - private _onTreeKeyDown(e: IKeyboardEvent): void { - const row = this._rowFromTarget(e.target); - if (!row || !isToolsTreeKeyboardTarget(e.target, row.element)) { + private _onSectionKeyDown(section: IToolsSectionList, e: IKeyboardEvent, getFocusBeforeKeyDown: () => number | undefined): void { + const entries = section.entries; + if (entries.length === 0) { return; } + const focusIndex = section.list.getFocus()[0]; + const entry = entries[focusIndex ?? 0]; let handled = true; switch (e.keyCode) { - case KeyCode.DownArrow: - this._focusRelative(row, 1); + case KeyCode.DownArrow: { + const before = getFocusBeforeKeyDown(); + handled = before !== undefined && before === entries.length - 1; + if (handled) { + this._focusAdjacentSection(section, 1); + } break; - case KeyCode.UpArrow: - this._focusRelative(row, -1); + } + case KeyCode.UpArrow: { + const before = getFocusBeforeKeyDown(); + handled = before !== undefined && before === 0; + if (handled) { + this._focusAdjacentSection(section, -1); + } break; + } case KeyCode.RightArrow: - handled = this._onExpandKey(row); + handled = this._onExpandKey(section, entry); break; case KeyCode.LeftArrow: - handled = this._onCollapseKey(row); + handled = this._onCollapseKey(section, entry); break; case KeyCode.Home: - this._focusEdge(true); + this._focusFirstOverall(); break; case KeyCode.End: - this._focusEdge(false); + this._focusLastOverall(); break; case KeyCode.Space: - if (row.readOnly) { - break; - } - row.toggleNode.click(); + this._onActivateKey(entry, false); break; case KeyCode.Enter: - if (row.kind === 'set' && row.readOnly) { - this._toggleCollapsed(row.toolSetId); - } else if (!row.readOnly) { - row.toggleNode.click(); - } + this._onActivateKey(entry, true); break; default: handled = false; @@ -958,46 +1135,149 @@ export class ToolsListWidget extends Disposable { } } - private _focusRelative(row: ITreeRow, delta: number): void { - const rows = this._visibleRows(); - const index = rows.indexOf(row); - const next = index === -1 ? undefined : rows[index + delta]; - if (next) { - this._focusRow(next); + /** + * Space always toggles enablement (no-op for read-only rows). Enter toggles enablement too, except + * on a read-only *set* row, where it expands/collapses instead (a read-only tool row does nothing). + * This mirrors the original mouse-vs-keyboard asymmetry, where clicking the row body (not its + * checkbox) toggles expand/collapse but Space/Enter on a focused row toggle its checkbox. + */ + private _onActivateKey(entry: IToolsRowEntry, viaEnter: boolean): void { + const readOnly = entry.kind === 'set' ? entry.vm.readOnly : entry.setVm.readOnly; + if (readOnly) { + if (viaEnter && entry.kind === 'set') { + this._toggleCollapsed(entry.vm.toolSet.id); + } + return; + } + if (entry.kind === 'set') { + const vm = entry.vm; + const current = getToolSetTriState(this._currentState(), vm.toolSet.id, vm.allToolIds); + this._enablementService.setToolSetEnabled(this._sessionType, vm.toolSet.id, vm.allToolIds, current !== true); + } else { + const { setVm, toolVm } = entry; + const current = isToolEnabledInSet(this._currentState(), setVm.toolSet.id, toolVm.tool.id); + this._enablementService.setToolEnabled(this._sessionType, setVm.toolSet.id, toolVm.tool.id, !current); } } - private _focusEdge(first: boolean): void { - const rows = this._visibleRows(); - this._focusRow(first ? rows[0] : rows[rows.length - 1]); - } - - /** Right arrow: expand a collapsed set, or move into its first child when already expanded. */ - private _onExpandKey(row: ITreeRow): boolean { - if (row.kind !== 'set') { + /** Right arrow: expand a collapsed set, or move into its first tool row when already expanded. */ + private _onExpandKey(section: IToolsSectionList, entry: IToolsRowEntry): boolean { + if (entry.kind !== 'set') { return false; } - if (!this._isExpanded(row)) { - this._setExpanded(row.toolSetId, true); - } else if (row.children!.length) { - this._focusRow(row.children![0]); + const vm = entry.vm; + if (!this._isRowExpanded(vm)) { + this._setExpanded(vm.toolSet.id, true); + } else if (vm.visibleTools.length) { + this._focusEntryInSection(section, `tool:${vm.toolSet.id}:${vm.visibleTools[0].tool.id}`); } return true; } /** Left arrow: collapse an expanded set, or move a tool row up to its parent set. */ - private _onCollapseKey(row: ITreeRow): boolean { - if (row.kind === 'set') { - if (this._isExpanded(row)) { - this._setExpanded(row.toolSetId, false); + private _onCollapseKey(section: IToolsSectionList, entry: IToolsRowEntry): boolean { + if (entry.kind === 'set') { + if (this._isRowExpanded(entry.vm)) { + this._setExpanded(entry.vm.toolSet.id, false); return true; } return false; } - this._focusRow(row.parent!); + this._focusEntryInSection(section, `set:${entry.setVm.toolSet.id}`); return true; } + private _focusEntryInSection(section: IToolsSectionList, rowId: string): void { + const index = section.entries.findIndex(e => this._entryRowId(e) === rowId); + if (index === -1) { + return; + } + section.list.setFocus([index]); + section.list.reveal(index); + section.list.domFocus(); + } + + /** Crosses into the adjacent section's first/last row when Up/Down hits the current section's edge. */ + private _focusAdjacentSection(from: IToolsSectionList, delta: 1 | -1): void { + let targetIndex = this._sectionLists.indexOf(from) + delta; + while (this._sectionLists[targetIndex]?.container.hidden) { + targetIndex += delta; + } + const target = this._sectionLists[targetIndex]; + if (!target) { + return; + } + if (target.entries.length === 0) { + this._focusAdjacentSection(target, delta); + return; + } + const index = delta === 1 ? 0 : target.entries.length - 1; + target.list.setFocus([index]); + target.list.reveal(index); + target.list.domFocus(); + } + + private _focusFirstOverall(): void { + const section = this._sectionLists.find(s => !s.container.hidden && s.entries.length > 0); + if (section) { + section.list.setFocus([0]); + section.list.reveal(0); + section.list.domFocus(); + } + } + + private _focusLastOverall(): void { + for (let i = this._sectionLists.length - 1; i >= 0; i--) { + const section = this._sectionLists[i]; + if (!section.container.hidden && section.entries.length > 0) { + const index = section.entries.length - 1; + section.list.setFocus([index]); + section.list.reveal(index); + section.list.domFocus(); + return; + } + } + } + + /** + * Subtitle for a tool-set row: the set's own `detail`, or for extension sets the extension's + * description (falling back to a generic "contributed by" label). + */ + private _resolveSetDetail(ts: IToolSet): string | undefined { + if (ts.detail) { + return ts.detail; + } + if (ts.source.type !== 'extension') { + return undefined; + } + const source = ts.source; + const extension = this._extensionsWorkbenchService.local.find(e => ExtensionIdentifier.equals(e.identifier.id, source.extensionId)); + return extension?.description || localize('toolsSetExtensionDetail', "Tools contributed by {0}", source.label); + } + + private _toggleCollapsed(toolSetId: string): void { + const next = new Set(this._expanded.get()); + if (next.has(toolSetId)) { + next.delete(toolSetId); + } else { + next.add(toolSetId); + } + this._expanded.set(next, undefined); + } + + private _setExpanded(toolSetId: string, expanded: boolean): void { + const next = new Set(this._expanded.get()); + if (expanded === next.has(toolSetId)) { + return; + } + if (expanded) { + next.add(toolSetId); + } else { + next.delete(toolSetId); + } + this._expanded.set(next, undefined); + } + private _currentState(): IToolEnablementState { return this._enablementService.getState(this._sessionType); } 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 f2dd156e904460..d4ee34ecdf4804 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 @@ -13,7 +13,8 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; -import { AICustomizationListWidget, getAlwaysVisibleCustomizationGroupKeys, getTargetedCreateActionLabel, usesCustomizationCardLayout } from '../../../browser/aiCustomization/aiCustomizationListWidget.js'; +import { AICustomizationListWidget, getAlwaysVisibleCustomizationGroupKeys, getCollapsedCustomizationGroupKey, getCustomizationItemAriaLabel, getTargetedCreateActionLabel, usesCustomizationCardLayout } from '../../../browser/aiCustomization/aiCustomizationListWidget.js'; +import { IAICustomizationListItem } from '../../../browser/aiCustomization/aiCustomizationItemSource.js'; import { IAICustomizationItemsModel } from '../../../browser/aiCustomization/aiCustomizationItemsModel.js'; import { extractExtensionIdFromPath, getCustomizationSecondaryText, truncateToFirstLine } from '../../../browser/aiCustomization/aiCustomizationListWidgetUtils.js'; import { AICustomizationManagementSection, IAICustomizationWorkspaceService } from '../../../common/aiCustomizationWorkspaceService.js'; @@ -25,7 +26,7 @@ import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/se import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; -import { createCustomizationCardPrimaryAction, CustomizationCardListController } from '../../../browser/aiCustomization/customizationCardList.js'; +import { createCustomizationCardPrimaryAction, CustomizationCardListController, layoutVirtualizedSectionList, layoutVirtualizedSections, renderVirtualizedSectionLoadingPlaceholder, setVirtualizedRowActionsTabbable, setupCollapsibleSection } from '../../../browser/aiCustomization/customizationCardList.js'; suite('aiCustomizationListWidget', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -74,6 +75,283 @@ suite('aiCustomizationListWidget', () => { ]); }); + test('collapsible sections are expanded by default and only the disclosure toggles them', () => { + const disposables = new DisposableStore(); + const heading = document.createElement('div'); + const content = document.createElement('div'); + const headerAction = document.createElement('button'); + heading.appendChild(headerAction); + const changes: boolean[] = []; + const toggle = setupCollapsibleSection(heading, content, 'Workspace', disposables, false, collapsed => changes.push(collapsed)); + + try { + headerAction.click(); + const initiallyExpanded = { + expanded: toggle.getAttribute('aria-expanded'), + controlsContent: toggle.getAttribute('aria-controls') === content.id, + hidden: content.hidden, + display: content.style.display, + changes: [...changes], + }; + toggle.click(); + const collapsed = { + expanded: toggle.getAttribute('aria-expanded'), + label: toggle.getAttribute('aria-label'), + hidden: content.hidden, + display: content.style.display, + changes: [...changes], + }; + toggle.click(); + + assert.deepStrictEqual({ + initiallyExpanded, + collapsed, + expandedAgain: { + expanded: toggle.getAttribute('aria-expanded'), + label: toggle.getAttribute('aria-label'), + hidden: content.hidden, + display: content.style.display, + changes, + }, + }, { + initiallyExpanded: { + expanded: 'true', + controlsContent: true, + hidden: false, + display: '', + changes: [], + }, + collapsed: { + expanded: 'false', + label: 'Expand Workspace', + hidden: true, + display: 'none', + changes: [true], + }, + expandedAgain: { + expanded: 'true', + label: 'Collapse Workspace', + hidden: false, + display: '', + changes: [true, false], + }, + }); + } finally { + disposables.dispose(); + } + }); + + test('collapsible sections create disclosures outside auxiliary document realms', () => { + const disposables = new DisposableStore(); + const auxiliaryDocument = document.implementation.createHTMLDocument(); + const heading = auxiliaryDocument.createElement('div'); + const content = auxiliaryDocument.createElement('div'); + Object.defineProperty(auxiliaryDocument, 'createElement', { + configurable: true, + value: () => { + throw new Error('Auxiliary documents must not create workbench controls'); + }, + }); + + try { + const toggle = setupCollapsibleSection(heading, content, 'Workspace', disposables, false, () => { }); + assert.deepStrictEqual({ + ownerDocument: toggle.ownerDocument === auxiliaryDocument, + parent: toggle.parentElement === heading, + expanded: toggle.getAttribute('aria-expanded'), + }, { + ownerDocument: true, + parent: true, + expanded: 'true', + }); + } finally { + disposables.dispose(); + } + }); + + test('collapsed groups are scoped to their customization page', () => { + assert.deepStrictEqual({ + agents: getCollapsedCustomizationGroupKey(AICustomizationManagementSection.Agents, PromptsStorage.local), + skills: getCollapsedCustomizationGroupKey(AICustomizationManagementSection.Skills, PromptsStorage.local), + }, { + agents: 'agents:local', + skills: 'skills:local', + }); + }); + + test('virtualized customization labels include item status', () => { + const item: IAICustomizationListItem = { + id: 'prompt', + uri: URI.file('Q:\\workspace\\.github\\prompts\\review.prompt.md'), + name: 'review', + displayName: 'Review', + filename: 'review.prompt.md', + description: 'Review the current changes', + source: PromptsStorage.local, + promptType: PromptsType.prompt, + disabled: false, + status: 'degraded', + }; + + assert.strictEqual(getCustomizationItemAriaLabel(item), 'Review. Review the current changes. Needs attention'); + }); + + test('virtualized row actions use a focused-row tab stop and skip disabled controls', () => { + const actions = document.createElement('div'); + const action = document.createElement('a'); + action.setAttribute('role', 'button'); + const toggle = document.createElement('div'); + toggle.setAttribute('role', 'switch'); + const disabledAction = document.createElement('a'); + disabledAction.setAttribute('role', 'button'); + disabledAction.setAttribute('aria-disabled', 'true'); + actions.append(action, toggle, disabledAction); + + setVirtualizedRowActionsTabbable(actions, true); + const focused = [action.tabIndex, toggle.tabIndex, disabledAction.tabIndex]; + setVirtualizedRowActionsTabbable(actions, false); + + assert.deepStrictEqual({ + focused, + unfocused: [action.tabIndex, toggle.tabIndex, disabledAction.tabIndex], + }, { + focused: [0, 0, -1], + unfocused: [-1, -1, -1], + }); + }); + + test('virtualized section height is redistributed when a sibling collapses', () => { + const root = document.createElement('div'); + const createSection = () => { + const section = document.createElement('section'); + const list = document.createElement('div'); + section.appendChild(list); + root.appendChild(section); + Object.defineProperty(list, 'offsetHeight', { configurable: true, get: () => list.hidden ? 0 : Number.parseFloat(list.style.height) || 100 }); + Object.defineProperty(section, 'offsetHeight', { configurable: true, get: () => 40 + list.offsetHeight }); + return list; + }; + const first = createSection(); + const second = createSection(); + Object.defineProperty(root, 'clientHeight', { configurable: true, value: 300 }); + + const expanded = layoutVirtualizedSections(root, [ + { container: first, contentHeight: 300, minimumHeight: 44 }, + { container: second, contentHeight: 300, minimumHeight: 44 }, + ]); + first.hidden = true; + const redistributed = layoutVirtualizedSections(root, [ + { container: first, contentHeight: 300, minimumHeight: 44 }, + { container: second, contentHeight: 300, minimumHeight: 44 }, + ]); + + assert.deepStrictEqual({ expanded, redistributed }, { + expanded: [110, 110], + redistributed: [0, 220], + }); + }); + + test('virtualized sections keep one complete row when the initial height is constrained', () => { + const root = document.createElement('div'); + const sections = Array.from({ length: 3 }, () => { + const section = document.createElement('section'); + const list = document.createElement('div'); + section.appendChild(list); + root.appendChild(section); + Object.defineProperty(list, 'offsetHeight', { configurable: true, get: () => Number.parseFloat(list.style.height) || 0 }); + Object.defineProperty(section, 'offsetHeight', { configurable: true, get: () => 40 + list.offsetHeight }); + return list; + }); + Object.defineProperty(root, 'clientHeight', { configurable: true, value: 180 }); + + const constrained = layoutVirtualizedSections(root, sections.map(container => ({ + container, + contentHeight: 300, + minimumHeight: 44, + }))); + + assert.deepStrictEqual({ + constrained, + overflow: root.style.overflow, + reservesPageScrollbarLane: root.classList.contains('virtualized-section-layout-overflow'), + }, { + constrained: [44, 44, 44], + overflow: 'visible', + reservesPageScrollbarLane: true, + }); + }); + + test('virtualized sections distribute remaining height after reserving complete rows', () => { + const root = document.createElement('div'); + const sections = [352, 88, 44, 220].map(contentHeight => { + const section = document.createElement('section'); + const list = document.createElement('div'); + section.appendChild(list); + root.appendChild(section); + Object.defineProperty(list, 'offsetHeight', { configurable: true, get: () => Number.parseFloat(list.style.height) || 44 }); + Object.defineProperty(section, 'offsetHeight', { configurable: true, get: () => 40 + list.offsetHeight }); + return { container: list, contentHeight, minimumHeight: 44 }; + }); + Object.defineProperty(root, 'clientHeight', { configurable: true, value: 349 }); + + const heights = layoutVirtualizedSections(root, sections); + + assert.deepStrictEqual({ + heights, + overflow: root.style.overflow, + reservesPageScrollbarLane: root.classList.contains('virtualized-section-layout-overflow'), + }, { + heights: [48, 48, 44, 48], + overflow: '', + reservesPageScrollbarLane: false, + }); + }); + + test('loading placeholders and replacement lists keep a stable row height and scroll position', () => { + const container = document.createElement('div'); + const placeholder = renderVirtualizedSectionLoadingPlaceholder(container, 'Loading customizations...', 44); + const list = { + scrollTop: 88, + layout: (height: number) => { + assert.strictEqual(height, 44); + list.scrollTop = 0; + }, + }; + + layoutVirtualizedSectionList(list, container, 44); + + assert.deepStrictEqual({ + placeholderHeight: placeholder.style.height, + containerHeight: container.style.height, + scrollTop: list.scrollTop, + }, { + placeholderHeight: '44px', + containerHeight: '44px', + scrollTop: 88, + }); + }); + + test('collapsed virtualized lists retain their scroll position', () => { + const container = document.createElement('div'); + let layoutCount = 0; + const list = { + scrollTop: 88, + layout: () => layoutCount++, + }; + + layoutVirtualizedSectionList(list, container, 0); + + assert.deepStrictEqual({ + containerHeight: container.style.height, + scrollTop: list.scrollTop, + layoutCount, + }, { + containerHeight: '0px', + scrollTop: 88, + layoutCount: 0, + }); + }); + test('card lists use roving focus and expose focused-row actions', async () => { const disposables = new DisposableStore(); const list = document.createElement('div'); @@ -402,5 +680,101 @@ suite('aiCustomizationListWidget', () => { assert.strictEqual(widget.element.querySelector('.list-container')!.style.height, '830px'); }); + + test('instruction rows use an overflow menu without loaded status or targeting badges', async () => { + const items = observableValue('test', [{ + id: 'instruction', + uri: URI.file('Q:\\workspace\\.github\\instructions\\typescript.instructions.md'), + name: 'TypeScript', + filename: 'typescript.instructions.md', + description: 'TypeScript instructions', + source: PromptsStorage.local, + promptType: PromptsType.instructions, + disabled: false, + badge: '*.ts', + status: 'loaded', + }]); + instaService.stub(IAICustomizationItemsModel, { + getItems: () => items, + getCount: () => observableValue('test', 1), + getPluginCount: () => observableValue('test', 0), + whenSectionLoaded: async () => { }, + getActiveItemSource: () => ({ onDidAICustomizationItemsChange: Event.None, fetchProviderItems: async () => [], fetchAICustomizationItems: async () => [], fetchSourceFolders: async () => [], sessionResource: URI.parse('test:///session'), dispose() { } }), + }); + const widget = disposables.add(instaService.createInstance(AICustomizationListWidget)); + document.body.appendChild(widget.element); + disposables.add(toDisposable(() => widget.element.remove())); + setLayoutHeights(widget, 500); + + await widget.setSection(AICustomizationManagementSection.Instructions); + widget.layout(800, 500); + + const row = widget.element.querySelector('.ai-customization-list-item'); + assert.deepStrictEqual({ + badgeDisplay: row?.querySelector('.item-badge')?.style.display, + statusDisplay: row?.querySelector('.item-status-icon')?.style.display, + hasOverflowAction: !!row?.querySelector('.item-right .codicon-ellipsis'), + sectionExpanded: widget.element.querySelector('.customization-section-toggle')?.getAttribute('aria-expanded'), + }, { + badgeDisplay: 'none', + statusDisplay: 'none', + hasOverflowAction: true, + sectionExpanded: 'true', + }); + }); + + test('async section rerenders discard disposed virtual lists before redistributing height', async () => { + const items = observableValue('test', []); + let completeLoading!: () => void; + const loading = new Promise(resolve => completeLoading = resolve); + instaService.stub(IAICustomizationItemsModel, { + getItems: () => items, + getCount: () => observableValue('test', 0), + getPluginCount: () => observableValue('test', 0), + whenSectionLoaded: () => loading, + getActiveItemSource: () => ({ onDidAICustomizationItemsChange: Event.None, fetchProviderItems: async () => [], fetchAICustomizationItems: async () => [], fetchSourceFolders: async () => [], sessionResource: URI.parse('test:///session'), dispose() { } }), + }); + const widget = disposables.add(instaService.createInstance(AICustomizationListWidget)); + document.body.appendChild(widget.element); + disposables.add(toDisposable(() => widget.element.remove())); + setLayoutHeights(widget, 500); + + const setSection = widget.setSection(AICustomizationManagementSection.Skills); + items.set([ + ...Array.from({ length: 4 }, (_, index): IAICustomizationListItem => ({ + id: `workspace-${index}`, + uri: URI.file(`Q:\\workspace\\.github\\skills\\workspace-${index}\\SKILL.md`), + name: `Workspace ${index}`, + filename: 'SKILL.md', + source: PromptsStorage.local, + promptType: PromptsType.skill, + disabled: false, + })), + ...Array.from({ length: 2 }, (_, index): IAICustomizationListItem => ({ + id: `user-${index}`, + uri: URI.file(`Q:\\user\\skills\\user-${index}\\SKILL.md`), + name: `User ${index}`, + filename: 'SKILL.md', + source: PromptsStorage.user, + promptType: PromptsType.skill, + disabled: false, + })), + ], undefined); + completeLoading(); + await setSection; + + const content = widget.element.querySelector('.distributed-section-layout')!; + Object.defineProperty(content, 'clientHeight', { configurable: true, value: 399 }); + widget.layout(500, 800); + + const sectionHeights = Array.from(content.querySelectorAll('.virtualized-section-list'), section => section.style.height); + assert.deepStrictEqual({ + sectionCount: sectionHeights.length, + hasExpandedSection: sectionHeights.some(height => Number.parseInt(height) > 44), + }, { + sectionCount: 2, + hasExpandedSection: true, + }); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts index b76432c5b56f5a..69a0cea3b82bcf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts @@ -14,6 +14,7 @@ import { IHoverService } from '../../../../../../platform/hover/browser/hover.js import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { AGENT_BUILTIN_CUSTOMIZATION_SCHEME } from '../../../../../../platform/agentHost/common/agentHostCustomizationUri.js'; import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { URI } from '../../../../../../base/common/uri.js'; import { AICustomizationManagementEditor, isCurrentPluginContributionNavigation } from '../../../browser/aiCustomization/aiCustomizationManagementEditor.js'; import { ChatConfiguration } from '../../../common/constants.js'; @@ -26,6 +27,7 @@ import { CustomizationMigrationCategoryId } from '../../../browser/aiCustomizati import type { ICustomizationSourceFolder } from '../../../common/customizationHarnessService.js'; import type { ICustomizationMigrationCategorySummary } from '../../../browser/aiCustomization/aiCustomizationWelcomePage.js'; import { AICustomizationManagementEditorInput } from '../../../browser/aiCustomization/aiCustomizationManagementEditorInput.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; suite('aiCustomizationManagementEditor', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -72,10 +74,12 @@ suite('aiCustomizationManagementEditor', () => { viewMode: 'list' | 'migration' | 'editor' | 'mcpDetail' | 'pluginDetail' | 'toolsDetail'; dimension: undefined; hoverService: IHoverService; + instantiationService: IInstantiationService; configurationService: IConfigurationService; editorDisposables: DisposableStore; harnessService: { activeSessionResource: ISettableObservable }; migrationListContainer: HTMLElement | undefined; + migrationSectionLists: readonly unknown[]; migrationMigrateButton: { enabled: boolean; label: string } | undefined; migrationTitleElement: HTMLElement | undefined; migrationDescriptionElement: HTMLElement | undefined; @@ -152,8 +156,10 @@ suite('aiCustomizationManagementEditor', () => { update() { }, }), } as unknown as IHoverService; + editor.instantiationService = workbenchInstantiationService({}, editor.editorPreviewDisposables); editor.configurationService = configurationService ?? createConfigurationServiceStub(); editor.migrationListContainer = undefined; + editor.migrationSectionLists = []; editor.migrationMigrateButton = undefined; editor.migrationTitleElement = undefined; editor.migrationDescriptionElement = undefined; @@ -605,6 +611,51 @@ suite('aiCustomizationManagementEditor', () => { } }); + test('virtualized migration rows keep checkbox selection and keyboard traversal aligned', () => { + const editor = createTestEditor(undefined, createConfigurationServiceStub({ + [ChatConfiguration.ChatCustomizationsPromptMigrationEnabled]: true, + })); + const promptFiles = Array.from({ length: 6 }, (_, index): MigratableConfiguration => ({ + uri: URI.file(`/workspace/.github/prompts/workspace-${index}.prompt.md`), + name: `workspace-${index}.prompt.md`, + storage: PromptsStorage.local, + type: PromptsType.prompt, + source: PromptFileSource.GitHubWorkspace, + })); + editor.customizationsByMigrationCategory = new Map([[CustomizationMigrationCategoryId.PromptFiles, promptFiles]]); + editor.activeMigrationCategoryId = CustomizationMigrationCategoryId.PromptFiles; + editor.migrationListContainer = document.createElement('div'); + Object.defineProperty(editor.migrationListContainer, 'clientHeight', { configurable: true, value: 500 }); + editor.migrationMigrateButton = { enabled: false, label: '' }; + document.body.appendChild(editor.migrationListContainer); + + try { + editor.renderCustomizationMigrationPage(); + const firstRow = editor.migrationListContainer.querySelector('.monaco-list-row[data-index="0"]'); + firstRow?.click(); + const lastVisibleMoreButton = editor.migrationListContainer.querySelector('.monaco-list-row[data-index="4"] .prompt-migration-more-action'); + const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); + Object.defineProperty(tabEvent, 'keyCode', { get: () => 9 }); + lastVisibleMoreButton?.dispatchEvent(tabEvent); + + assert.deepStrictEqual({ + firstRowSelected: firstRow?.classList.contains('selected'), + firstRowAriaSelected: firstRow?.getAttribute('aria-selected') === 'true', + focusedRowIndex: document.activeElement?.closest('.monaco-list-row')?.getAttribute('data-index'), + focusedControlIsCheckbox: document.activeElement?.classList.contains('monaco-checkbox'), + }, { + firstRowSelected: false, + firstRowAriaSelected: false, + focusedRowIndex: '5', + focusedControlIsCheckbox: true, + }); + } finally { + editor.migrationListContainer.remove(); + editor.migrationPageDisposables.dispose(); + editor.editorPreviewDisposables.dispose(); + } + }); + test('group migration selection retains keyboard focus', () => { const editor = createTestEditor(undefined, createConfigurationServiceStub({ [ChatConfiguration.ChatCustomizationsPromptMigrationEnabled]: true, @@ -631,6 +682,7 @@ suite('aiCustomizationManagementEditor', () => { editor.setCustomizationSelectedForMigration(promptFile, true); } editor.migrationListContainer = document.createElement('div'); + Object.defineProperty(editor.migrationListContainer, 'clientHeight', { configurable: true, value: 500 }); editor.migrationMigrateButton = { enabled: false, label: '' }; document.body.appendChild(editor.migrationListContainer); @@ -782,6 +834,7 @@ suite('aiCustomizationManagementEditor', () => { editor.setCustomizationSelectedForMigration(promptFile, true); } editor.migrationListContainer = document.createElement('div'); + Object.defineProperty(editor.migrationListContainer, 'clientHeight', { configurable: true, value: 500 }); editor.migrationTitleElement = document.createElement('h2'); editor.migrationDescriptionElement = document.createElement('p'); editor.migrationLinkElement = document.createElement('a'); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts index 7d19ecffcb23ca..26c8943a589b90 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts @@ -893,6 +893,7 @@ suite('mcpListWidget', () => { } as unknown as ICustomizationHarnessService; const renderer = new McpServerItemRenderer( async () => { }, + () => { }, { isSessionsWindow: true } as IAICustomizationWorkspaceService, { plugins: observableValue('plugins', []) } as unknown as IAgentPluginService, { setupManagedHover: () => Disposable.None } as unknown as IHoverService, @@ -925,6 +926,13 @@ suite('mcpListWidget', () => { const button = ctx.actionNode(); assert.ok(button, 'expected an action for an erroring server'); + assert.deepStrictEqual({ + text: ctx.templateData.statusBadge.textContent, + className: ctx.templateData.statusBadge.className, + }, { + text: 'Error', + className: 'plugin-list-item-status mcp-runtime-status-badge error', + }); // What the autorun does in production while a server sits in error. for (let i = 0; i < 10; i++) { 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 7ff2424d595c7d..e03d6dc93764ad 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -1159,6 +1159,10 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor } } + if (options.migrationCategory) { + editor.showCustomizationMigrationPage(options.migrationCategory); + } + if (options.migrationPartialSelection) { let firstMigrationCheckbox: HTMLElement | null = null; for (let attempt = 0; attempt < 20 && !firstMigrationCheckbox; attempt++) { @@ -1177,10 +1181,6 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor await new Promise(resolve => setTimeout(resolve, 1400)); } - if (options.migrationCategory) { - editor.showCustomizationMigrationPage(options.migrationCategory); - } - 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; @@ -1867,8 +1867,8 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { isSessionsWindow: true, selectedSection: AICustomizationManagementSection.McpServers, activeSessionMcpServers, - mcpSearchQuery: 'Remote Browser', openFirstItem: true, + openItemLabel: 'Remote Browser', }), }), diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 2a258fd3b745e6..356ed316dc3026 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -7,16 +7,16 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/c13f82b9dbe63bd2450bb28f48d66704613bfa02a36b47c6a05a3c9b4d0b81ea) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/80f87f875ed4c2e57f538d498dfae808fb0fb0599d966dace61b135c467ff794) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/28afa7579bb0d20cb18d370984ef36f74671b4d62dbf00ef5195e3f39817d202) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/9bd3ed05110dbe9c193acf371e2af656926d8f223faba768a8acd7f009bd7485) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/896f225ad9c8c34648d178723be3c09be772331fc369e2053bfdeda50561fdfe) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTabNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/80d131368e6930bc9135e9d47f57dbcf121588dab5718cfda4b40c8543a3e620) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/8478954d28888f188b20cf5b1eb3a919c091316085c3b81106480075cf92d00c) #### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTabNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/08e0a7af4d6954232fd0de9008b3c0f4936d31b4a286446f44011f463fe23b41) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/006b83d675a7f58ae5523b9b518f93b34b63aeb48ff774c9cf93129214ff41ac) #### chat/aiCustomizations/aiCustomizationManagementEditor/EmbeddedMcpDetailUninstalled/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/a5c4c329b3df33c748b8cd46ba7585490b58b2d42fa71c25a44a7f327b344b1b) @@ -25,76 +25,76 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/b150bed8ecf034a2ac61bc46081007101d109ed98bc176824e045965dfff94da) #### chat/aiCustomizations/aiCustomizationManagementEditor/HooksEmptyWorkspace/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/584a93e3dbed1d8f7ffcff61cb6d9617fc60e09beed1f2f07ed759cf1359d960) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e325f9d62356e7dcbe47d010d40da5a4b2e0e14bab822b1d1af7c809e90ab783) #### chat/aiCustomizations/aiCustomizationManagementEditor/HooksEmptyWorkspace/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/cd215308df295015347d508181e37f594152b90c4d29ae8d93c6b76fe3820ad7) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/3cc3dc7395a62e03c87a7474db2382cff6ea7e7240e206e3b076b89c53458bb4) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServerDetailNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/381816517a26c12e647f06d53e86100e1c67445bb76ea60ee3dcd981ffeaa41d) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/dcbe6f881e9d1d94691bc87ecd800253c237f73e30341b7f88f8afbf588a282d) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServerDetailNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b8c214bd7418237091cedd35cb8e5abb117e9b0741c8c108000939306595e0ec) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/cfb82a5399bf015b29c48bdb7f7f4224d13a6bfa4ea0ae9cb8efa67948c2f4ad) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/f0544aedf46f4e4f082b31fbf9572639656ce6e149a2bacc6114da6b751b6faa) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/64a9ba55fca8dbd08bf3ce5118f1b78fedc3d97231b6c92700542b720ea1d23c) #### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/109b64f5e81c0da1bd6e00a1bcbb94edb3a3099c95a49bc9578605b055dd5175) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/92d2e1fa82290461764a4d0ba075994e42e969f6994d2d45dd1727ad36b6280f) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHome/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/4e881a199c6371613745f5298984d63de42aac4d0eaabb3c4811c9bcda00205a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/e3eee532373366a533ac4374808d799b1a2cfdc7db83ad479ae38eac2102e670) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHome/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/aca2145ec8c31a48c033dd291119b5c74d4782728215013939d18e21b3889a83) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/1cb59613169dce3604a71ffc9b9a7460de175e78d323436e6427299730ee0a76) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHomeNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/df5b59c82075a7531e8ceffd16f7098626863f731bd099970ecda88efa3d9a40) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/9b4ea6512c19a7999a18d857ff15140d13fbfea69d32d63b0aa871739d4c5af5) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogHomeNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/8895e63f1e5e0a369181cf60128aa984e258592ff85b6b87e57d0c604360888a) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/13d81ef8758630efebb534de263c87ec72f24fea419305539c3e9449b08dd838) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogSearch/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/d39e63162436cf09ff71cd6427d6c808ad23889f00bf73240e089d887fba374e) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/1c73380dc80120ef5cf9a9ad9998701074fd71101eba681d9c3c53bb1d14f450) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginCatalogSearch/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/94ed07ad627037f1c98d1e9767449991e6f4ebac3c25d3dc265c03cb5cb930fc) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/32e4715053eeba6b457cf67e2069ab08c9d1428e088c4203e0de8a4c92b96b36) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginDetail/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/f9c703b94481f8bd41d4154fe26df01d2a502eae7b1a917ed406fe1f72ac34df) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/62537479c1fd43a5592ff159693c958b4d740da4d52066bc2731172d656ec0e5) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginDetail/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/d0b6575b59c8658eb961d0f022cf4943bfc9477b9e1bbf65f9de5a08ab773149) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a17dbb65cd39f1dfd3528afeab8ab67b57b69926a440b0b74dab7e4d2ec52ac1) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/f9c703b94481f8bd41d4154fe26df01d2a502eae7b1a917ed406fe1f72ac34df) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/62537479c1fd43a5592ff159693c958b4d740da4d52066bc2731172d656ec0e5) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/d0b6575b59c8658eb961d0f022cf4943bfc9477b9e1bbf65f9de5a08ab773149) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a17dbb65cd39f1dfd3528afeab8ab67b57b69926a440b0b74dab7e4d2ec52ac1) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTabNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/0de50692f3dc96a1396e2a820e6cd45a5be7f6625e7b49565089b8e929927c0e) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/cd5ba64dc1d4335dee027f901961b54fba261082989985ae52ae9e95b05ade37) #### chat/aiCustomizations/aiCustomizationManagementEditor/PluginsTabNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/4b3c83e0b3b4f3e116e9f38ffe48ad7bf5a9b9b4656e233c8394196eae9133bc) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/74e7da25f15ef10984935651a1b898d512675cdd18c5d6485b2fe028c3503d18) #### chat/aiCustomizations/aiCustomizationManagementEditor/PromptMigration/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/13f0fd52bed5191a9fafe05be00a7757e72a92f05162a2f323a31663b9ddc83c) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/ff29022d8f4218b9bab6b727e2dc102313349ee3741b7e09c91f1d3699819045) #### chat/aiCustomizations/aiCustomizationManagementEditor/PromptMigration/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/45a9ae5a6c6ce5f5d9184ae55d124e13f6625c805b72a6d52b17035545ab7278) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/df1b9208d7da611a7eb80cfe78e6ec67993c84263f895dbdefdba15f054fdab4) #### chat/aiCustomizations/aiCustomizationManagementEditor/ToolsTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/afe68d55d548d3234536e2e69147f8c48de5dac0e1eb09b1ff656337c370d664) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/fb6cf3c234695edd7425429cd80f8831a099ebf31627eff7a6da34fd1a39e978) #### chat/aiCustomizations/aiCustomizationManagementEditor/ToolsTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/4f4bf2ba517c50d456a557fb27ff423cce77e24adf626754af2cb055dd931761) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/61ae20aec4f4fb94a34c7c030b90d20f7dc214413a335aed4d91260cf97e8937) #### chat/aiCustomizations/aiCustomizationManagementEditor/UserDataMigration/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5483d9af839d6e44f95c61701cb3e03bd578e6ba1465f4724d9231654d775549) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/da9c970c065f09bcc641277cc354827e578a912fbea692e41312e4d0a310291a) #### chat/aiCustomizations/aiCustomizationManagementEditor/UserDataMigration/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/7203d034fad4d4439b6f70ac49ed02f95d764a090f8067a06bb6bae7d4bd1107) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/37be569b9695d50b0c1ca515bef743bd743cf5009502a3cbf85ba86a841c5ba5) #### chat/aiCustomizations/aiCustomizationManagementEditor/WelcomePage/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/483f8579ecd4518666859e405e43c08aec24e1a857a05f242517c1916ab21b9a)