diff --git a/src/vs/platform/agentHost/common/copilotConfigSlashCommands.ts b/src/vs/platform/agentHost/common/copilotConfigSlashCommands.ts index 0b0c8f4aeb12d0..4960a0e67e0d30 100644 --- a/src/vs/platform/agentHost/common/copilotConfigSlashCommands.ts +++ b/src/vs/platform/agentHost/common/copilotConfigSlashCommands.ts @@ -239,6 +239,7 @@ export function resolveCopilotConfigSlashCommandOnSend(command: string, rest: st } const trimmedRest = rest.trim(); const namedOptions = descriptor.options.filter(o => o.arg !== undefined); + const baseOption = descriptor.options.find(o => o.arg === undefined); if (namedOptions.length > 0 && trimmedRest.length > 0) { const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(trimmedRest); const firstToken = match?.[1]?.toLowerCase(); @@ -246,8 +247,11 @@ export function resolveCopilotConfigSlashCommandOnSend(command: string, rest: st if (matched) { return { applyConfig: matched.config, strippedPrompt: (match?.[2] ?? '').trim() }; } + if (!baseOption) { + return undefined; + } } // Fall back to the bare command form (the base/prompt option or the sole option). - const baseOption = descriptor.options.find(o => o.arg === undefined) ?? descriptor.options[0]; - return { applyConfig: baseOption.config, strippedPrompt: trimmedRest }; + const fallback = baseOption ?? descriptor.options[0]; + return { applyConfig: fallback.config, strippedPrompt: trimmedRest }; } diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index c3c1c02d00973c..28195e31409ddb 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -41,25 +41,32 @@ export type IAgentHostUserMessageSentClassification = { }; export type AgentHostTurnResult = 'success' | 'error' | 'cancelled'; +type AgentHostModelSelectionKind = 'default' | 'auto' | 'explicit'; export interface IAgentHostTurnCompletedEvent { provider: string; agentSessionId: string; + turnId: string; timeToFirstProgress: number | undefined; totalTime: number; result: AgentHostTurnResult; model: string | undefined; + modelSelectionKind: AgentHostModelSelectionKind; permissionLevel: string | undefined; + errorType: string | undefined; } export type IAgentHostTurnCompletedClassification = { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; + turnId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The identifier of the turn within the agent host session.' }; timeToFirstProgress: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds from turn start to the first visible progress (text delta, response part, tool call start, or reasoning).' }; totalTime: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total time in milliseconds from turn start to turn completion.' }; result: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the turn completed successfully, with an error, or was cancelled.' }; model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model identifier selected for the session at turn start (e.g. gemini-3.5-flash).' }; + modelSelectionKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the client used the provider default, Auto, or an explicit model.' }; permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The tool auto-approval level configured for the session at turn start (e.g. default, autoApprove, autopilot).' }; + errorType: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The structured agent host or provider error type when the turn fails.' }; owner: 'roblourens'; comment: 'Tracks agent host turn performance including time to first visible progress and total turn duration.'; }; @@ -67,11 +74,13 @@ export type IAgentHostTurnCompletedClassification = { export interface IAgentHostTurnCompletedReport { provider: string; session: string; + turnId: string; timeToFirstProgress: number | undefined; totalTime: number; result: AgentHostTurnResult; model: string | undefined; permissionLevel: string | undefined; + errorType: string | undefined; } export interface IAgentHostToolInvokedReport { @@ -429,11 +438,14 @@ export class AgentHostTelemetryReporter { this._telemetryService.publicLog2('agentHost.turnCompleted', { provider: report.provider, agentSessionId: AgentSession.id(session), + turnId: report.turnId, timeToFirstProgress: report.timeToFirstProgress, totalTime: report.totalTime, result: report.result, model: report.model, + modelSelectionKind: report.model === undefined ? 'default' : report.model === 'auto' ? 'auto' : 'explicit', permissionLevel: report.permissionLevel, + errorType: report.errorType, }); } diff --git a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts index db35fee7b01c62..02abd2613d09c8 100644 --- a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts +++ b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts @@ -603,9 +603,24 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe ? tracker.parser.parseSegments(rawData) : (rawData.length > 0 ? [{ kind: 'data', data: rawData }] : []); - let cleanedForClient = ''; + // Preserve OSC 633 stream order when emitting AHP actions: command data must remain between + // TerminalCommandExecuted and TerminalCommandFinished, matching the AHP contract and xterm. + let pendingClientData = ''; + const flushClientData = (): void => { + if (pendingClientData.length === 0) { + return; + } + managed.onDataEmitter.fire(pendingClientData); + this._stateManager.dispatchServerAction(managed.uri, { + type: ActionType.TerminalData, + data: pendingClientData, + }); + pendingClientData = ''; + }; + for (const segment of segments) { if (segment.kind === 'event') { + flushClientData(); this._handleOsc633Event(managed, tracker!, segment.event); continue; } @@ -616,21 +631,14 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe const cleanedData = removeServerHandledTerminalQueries(segment.data, managed.terminalQueryFilterState); if (cleanedData.length > 0) { this._appendToContent(managed, cleanedData); - cleanedForClient += cleanedData; + pendingClientData += cleanedData; } } + flushClientData(); + // Trim content if too large this._trimContent(managed); - - // Fire data event and dispatch to protocol (cleaned, without OSC 633) - if (cleanedForClient.length > 0) { - managed.onDataEmitter.fire(cleanedForClient); - this._stateManager.dispatchServerAction(managed.uri, { - type: ActionType.TerminalData, - data: cleanedForClient, - }); - } } /** Handle a parsed OSC 633 event by dispatching the appropriate protocol actions. */ diff --git a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts index 689060ce24795e..bc836224d209c5 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts @@ -51,7 +51,7 @@ export class AgentHostTurnTracker { } } - turnCompleted(session: string, turnId: string, result: AgentHostTurnResult): void { + turnCompleted(session: string, turnId: string, result: AgentHostTurnResult, errorType?: string): void { const key = this._key(session, turnId); const timing = this._turnTimings.get(key); if (!timing) { @@ -62,11 +62,13 @@ export class AgentHostTurnTracker { this._reporter.turnCompleted({ provider: timing.provider, session: timing.session, + turnId, timeToFirstProgress: timing.firstProgressMs, totalTime: timing.stopWatch.elapsed(), result, model: timing.model, permissionLevel: timing.permissionLevel, + errorType, }); } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 6a7e800355feb3..7953976ce20347 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -728,7 +728,7 @@ export class AgentSideEffects extends Disposable { } if (action.type === ActionType.ChatError) { - this._turnTracker.turnCompleted(sessionKey, turnId, 'error'); + this._turnTracker.turnCompleted(sessionKey, turnId, 'error', action.error.errorType); this._toolCallTracker.clearSession(sessionKey); this._markSessionUnread(sessionUri); } @@ -1524,16 +1524,17 @@ export class AgentSideEffects extends Disposable { const sessionStatus = this._stateManager.getSessionSummary(options.sessionChannel)?.status ?? 0; const sessionArchived = (sessionStatus & SessionStatus.IsArchived) === SessionStatus.IsArchived; if (isChatReadOnly(chatState?.interactivity, sessionArchived)) { + const error = sessionArchived + ? { errorType: 'archived', message: 'This session is archived and read-only. Restore the session to continue the conversation.' } + : { errorType: 'readOnly', message: 'This chat is read-only.' }; this._logService.warn(`[AgentSideEffects] Rejecting turn on read-only chat=${chat} (archived=${sessionArchived}), turnId=${turnId}`); this._stateManager.dispatchServerAction(turnChannel, { type: ActionType.ChatError, turnId, duration: this._turnDuration(turnStopWatch), - error: sessionArchived - ? { errorType: 'archived', message: 'This session is archived and read-only. Restore the session to continue the conversation.' } - : { errorType: 'readOnly', message: 'This chat is read-only.' }, + error, }); - this._turnTracker.turnCompleted(turnChannel, turnId, 'error'); + this._turnTracker.turnCompleted(turnChannel, turnId, 'error', error.errorType); this._toolCallTracker.clearSession(turnChannel); return; } @@ -1562,14 +1563,15 @@ export class AgentSideEffects extends Disposable { await agent.chats.sendMessage(chatUri, message.text, resolvedWorkingDirectory, message.attachments, turnId, senderClientId); } catch (err) { const errCode = (err as { code?: number })?.code; + const error = buildSendFailedError(err); this._logService.error(`[AgentSideEffects] sendMessage failed for session=${turnChannel}: code=${errCode}, message=${err instanceof Error ? err.message : String(err)}, type=${err?.constructor?.name}`, err); this._stateManager.dispatchServerAction(turnChannel, { type: ActionType.ChatError, turnId, duration: this._turnDuration(turnStopWatch), - error: buildSendFailedError(err), + error, }); - this._turnTracker.turnCompleted(turnChannel, turnId, 'error'); + this._turnTracker.turnCompleted(turnChannel, turnId, 'error', error.errorType); this._toolCallTracker.clearSession(turnChannel); this._failSessionCreationIfStillCreating(sessionChannel, err); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts index 7131d13711a6fa..b1a3c7f7e219ae 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts @@ -52,9 +52,8 @@ export interface ICopilotRuntimeSlashCommandQueryOptions { * * The returned items carry a {@link MessageAttachmentKind.Simple} * attachment, which the workbench bridge maps into command/skill completion - * attachments. Command dispatch happens text-side in - * `CopilotAgentSession.send` via {@link parseLeadingSlashCommand}, so the - * feature works whether the user picks the item or types it manually. + * attachments. Runtime command dispatch is text-side in `CopilotAgentSession.send`; + * client-side config commands also share the same leading slash parser. */ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompletionItemProvider { readonly kinds: ReadonlySet = new Set([CompletionItemKind.UserMessage]); diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index cfa245fbe4fa06..a08c6285c259e3 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -536,6 +536,39 @@ suite('TerminalStateSubscription', () => { ]); }); + test('data between command executed and finished is attributed to the command', () => { + const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop)); + sub.handleSnapshot(makeTerminalState(), 0); + + // The server dispatches data in stream order relative to command + // events, so a command's output arrives between the executed and + // finished actions and must land in the command part, not in a + // trailing unclassified part. + sub.receiveEnvelope(makeEnvelope( + { type: ActionType.TerminalCommandExecuted, commandId: 'cmd-1', commandLine: 'echo hi', timestamp: 1000 }, + 1, + )); + sub.receiveEnvelope(makeEnvelope( + { type: ActionType.TerminalData, data: 'hi\r\n' }, + 2, + )); + sub.receiveEnvelope(makeEnvelope( + { type: ActionType.TerminalCommandFinished, commandId: 'cmd-1', exitCode: 0, durationMs: 5 }, + 3, + )); + + assert.deepStrictEqual((sub.value as TerminalState).content, [{ + type: 'command', + commandId: 'cmd-1', + commandLine: 'echo hi', + output: 'hi\r\n', + timestamp: 1000, + isComplete: true, + exitCode: 0, + durationMs: 5, + }]); + }); + test('ignores terminal actions for other URIs', () => { const sub = disposables.add(new TerminalStateSubscription(terminalUri, 'c1', noop)); sub.handleSnapshot(makeTerminalState(), 0); diff --git a/src/vs/platform/agentHost/test/common/copilotConfigSlashCommands.test.ts b/src/vs/platform/agentHost/test/common/copilotConfigSlashCommands.test.ts index df5d95837f0431..2dc5aa20401834 100644 --- a/src/vs/platform/agentHost/test/common/copilotConfigSlashCommands.test.ts +++ b/src/vs/platform/agentHost/test/common/copilotConfigSlashCommands.test.ts @@ -69,6 +69,8 @@ suite('copilotConfigSlashCommands', () => { assert.deepStrictEqual(resolveCopilotConfigSlashCommandOnSend('autopilot', 'do the thing'), { applyConfig: { mode: 'autopilot' }, strippedPrompt: 'do the thing' }); // `plan` has no sub-args, so trailing text is forwarded as the prompt. assert.deepStrictEqual(resolveCopilotConfigSlashCommandOnSend('plan', 'the feature'), { applyConfig: { mode: 'plan' }, strippedPrompt: 'the feature' }); + assert.strictEqual(resolveCopilotConfigSlashCommandOnSend('yolo', 'onxxxcva'), undefined); + assert.strictEqual(resolveCopilotConfigSlashCommandOnSend('allow-all', 'offxxxcva'), undefined); assert.strictEqual(resolveCopilotConfigSlashCommandOnSend('notACommand', 'x'), undefined); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts index 55b1e03d070167..9255f113c5e82c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTerminalManager.test.ts @@ -58,11 +58,26 @@ class TestTerminalDataHandler { handlePtyData(rawData: string): string { let cleanedForClient = ''; - // Process cleaned-data and events in stream order so that output which - // arrives before a CommandFinished marker is appended to the command - // before the finished event snapshots it — see _handlePtyData. + // Data is dispatched in stream order relative to command events: flush + // pending data before handling each event so subscribers observe + // CommandExecuted -> data -> CommandFinished exactly like the raw + // stream — see _handlePtyData. + let pendingClientData = ''; + const flushClientData = (): void => { + if (pendingClientData.length === 0) { + return; + } + this.dispatched.push({ + type: ActionType.TerminalData, + data: pendingClientData, + }); + cleanedForClient += pendingClientData; + pendingClientData = ''; + }; + for (const segment of this.tracker.parser.parseSegments(rawData)) { if (segment.kind === 'event') { + flushClientData(); this._handleOsc633Event(segment.event); continue; } @@ -70,10 +85,12 @@ class TestTerminalDataHandler { const cleanedData = removeServerHandledTerminalQueries(segment.data, this._terminalQueryFilterState); if (cleanedData.length > 0) { this._appendToContent(cleanedData); - cleanedForClient += cleanedData; + pendingClientData += cleanedData; } } + flushClientData(); + return cleanedForClient; } @@ -512,7 +529,11 @@ suite('AgentHostTerminalManager – command detection integration', () => { assert.strictEqual(cleaned, 'beforemidafter'); assert.deepStrictEqual(handler.content, [{ type: 'unclassified', value: 'beforemidafter' }]); - assert.strictEqual(handler.dispatched[0].type, ActionType.TerminalCommandDetectionAvailable); + assert.deepStrictEqual(handler.dispatched, [ + { type: ActionType.TerminalData, data: 'before' }, + { type: ActionType.TerminalCommandDetectionAvailable }, + { type: ActionType.TerminalData, data: 'midafter' }, + ]); }); test('TerminalCommandDetectionAvailable is dispatched on first OSC 633', () => { @@ -687,7 +708,9 @@ suite('AgentHostTerminalManager – command detection integration', () => { assert.deepStrictEqual(handler.content, [ { type: 'unclassified', value: data }, ]); - assert.deepStrictEqual(handler.dispatched, []); + assert.deepStrictEqual(handler.dispatched, [ + { type: ActionType.TerminalData, data }, + ]); }); test('CommandFinished without active command is ignored', () => { @@ -748,8 +771,26 @@ suite('AgentHostTerminalManager – command detection integration', () => { output: event.output, }))); + // Clients rebuild per-command output from the action stream, so the + // data must also be DISPATCHED between the executed and finished + // actions, not after the whole chunk. + const dispatched: { type: string; data?: string }[] = []; + disposables.add(stateManager.onDidEmitEnvelope(envelope => { + const action = envelope.action; + if (action.type === ActionType.TerminalCommandExecuted || action.type === ActionType.TerminalCommandFinished) { + dispatched.push({ type: action.type }); + } else if (action.type === ActionType.TerminalData) { + dispatched.push({ type: action.type, data: action.data }); + } + })); + pty.fireData(`${osc633('C')}hi\r\n${osc633('D;0')}`); assert.deepStrictEqual(completions, [{ exitCode: 0, output: 'hi\r\n' }]); + assert.deepStrictEqual(dispatched, [ + { type: ActionType.TerminalCommandExecuted }, + { type: ActionType.TerminalData, data: 'hi\r\n' }, + { type: ActionType.TerminalCommandFinished }, + ]); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 9f7d02475511de..fe9f7d331d7665 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -195,8 +195,10 @@ suite('AgentSideEffects — turn tracker telemetry', () => { const data = events[0].data as Record; assert.strictEqual(data.provider, 'mock'); assert.strictEqual(data.agentSessionId, 'session-1'); + assert.strictEqual(data.turnId, 'turn-1'); assert.strictEqual(data.result, 'success'); assert.strictEqual(data.model, 'gpt-5.5'); + assert.strictEqual(data.modelSelectionKind, 'explicit'); assert.strictEqual(data.permissionLevel, 'autopilot'); assert.strictEqual(typeof data.totalTime, 'number'); assert.strictEqual(typeof data.timeToFirstProgress, 'number'); @@ -216,12 +218,13 @@ suite('AgentSideEffects — turn tracker telemetry', () => { test('emits result=cancelled on ChatTurnCancelled', () => { setupSession(); - startTurn('turn-1'); + startTurn('turn-1', 'hello', 'auto'); fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-1', duration: 1000 }); const events = completedEvents(); assert.strictEqual(events.length, 1); assert.strictEqual((events[0].data as Record).result, 'cancelled'); + assert.strictEqual((events[0].data as Record).modelSelectionKind, 'auto'); }); test('emits result=error on ChatError', () => { @@ -232,6 +235,7 @@ 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).errorType, 'oops'); }); test('emits a single turnCompleted per turn even when followed by duplicate completions', () => { @@ -266,6 +270,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { const data = completedEvents()[0].data as Record; assert.strictEqual(data.model, undefined); + assert.strictEqual(data.modelSelectionKind, 'default'); assert.strictEqual(data.permissionLevel, undefined); }); @@ -302,6 +307,7 @@ 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).errorType, 'sendFailed'); }); test('emits result=error when a queued sendMessage rejects', async () => { diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 879158e5d8a1db..fd31c86f1cecf0 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -79,6 +79,8 @@ send: composer → management.sendNewChatRequest() // model: provider focus a slot: part.onDidFocusSession → view.setActive → updates active visible slot ``` +The Agents-window chat surface also registers the workbench chat pre-submit handlers. These handlers can consume provider-specific client-side commands before the normal send path, while the actual send still routes through the sessions provider model. + The part (interface `services/sessions/browser/sessionsPartService.ts`; concrete `browser/parts/sessionsPart.ts`) is a **passive renderer**: it injects neither the model nor the view, and only exposes `updateVisibleSessions(visible, active)`, `focusSession`, and `onDidFocusSession`. The view owns the reconcile autorun and focus and wires `part.onDidFocusSession → view.setActive`. ### Layer 3 — Providers (`contrib/providers/`) @@ -193,6 +195,8 @@ The session type picker persists the last selection as `{ providerId, sessionTyp On reload, providers register asynchronously and agent hosts connect lazily, so the preferred provider may not have surfaced its session types when the restored draft is created. Rather than blocking on a "ready" gate, `NewChatWidget` creates the draft immediately with the best available provider, then upgrades it in place once the preferred `(providerId, sessionTypeId)` pair becomes servable (driven by `onDidChangeSessionTypes`). The upgrade listener lives for the widget's lifetime — there is **no** timeout or `LifecyclePhase` give-up, since an agent host can connect arbitrarily late — and is cancelled if the user picks a different type or the draft is sent. +Scheduled automations follow the same lazy-registration rule. Before claiming a run row, `AutomationRunner` checks whether its exact target is currently advertised; an unavailable target is deferred without advancing `nextRunAt`, and `AutomationScheduler` retries due automations when `onDidChangeSessionTypes` fires. Once a draft exists, an explicitly selected model waits on `getModelsSnapshot` / `onDidChangeModels` until it is available or conclusively unavailable, re-checking folder-specific session types for workspace-backed drafts. No startup delay or readiness timeout is used. + ### Quick Chats A **quick chat** is a workspace-less session — one that is not scoped to any folder, so `ISession.workspace` resolves to `undefined`. Quick chats let the user start a conversation immediately, without first picking a repository or worktree. diff --git a/src/vs/sessions/contrib/automations/browser/automationRunner.ts b/src/vs/sessions/contrib/automations/browser/automationRunner.ts index bd449f7f760c74..d55beef3d5605f 100644 --- a/src/vs/sessions/contrib/automations/browser/automationRunner.ts +++ b/src/vs/sessions/contrib/automations/browser/automationRunner.ts @@ -80,20 +80,6 @@ export class AutomationRunner implements IAutomationRunner { return; } - const run = await this.automationService.recordRunStart(automation.id, trigger, leaderWindowId); - runId = run.id; - await this.automationService.updateRun(runId, { status: 'running' }); - - if (token.isCancellationRequested) { - await this._markCancelled(runId, trigger, automation, startTimeMs); - return; - } - - const options: ISendRequestOptions = { - query: automation.prompt, - background: true, - title: automation.name?.substring(0, 100), - }; const target = automation.target; const isolationMode = target.kind === 'workspace' ? target.isolation.kind === 'folder' ? 'workspace' : target.isolation.kind === 'worktree' ? 'worktree' : undefined @@ -112,6 +98,32 @@ export class AutomationRunner implements IAutomationRunner { } : undefined; + const targetAvailable = target.kind === 'quickChat' + ? this.sessionsManagementService.isQuickChatTargetAvailable(createOptions) + : this.sessionsManagementService.isNewSessionTargetAvailable(target.folderUri, createOptions); + if (!targetAvailable) { + this.logService.trace(`[AutomationRunner] deferring ${automation.id}: target is not yet advertised.`); + if (trigger === 'manual') { + this.notificationService.info(localize('automationTargetUnavailable', "Automation '{0}' cannot start until its agent becomes available.", automation.name)); + } + return; + } + + const run = await this.automationService.recordRunStart(automation.id, trigger, leaderWindowId); + runId = run.id; + await this.automationService.updateRun(runId, { status: 'running' }); + + if (token.isCancellationRequested) { + await this._markCancelled(runId, trigger, automation, startTimeMs); + return; + } + + const options: ISendRequestOptions = { + query: automation.prompt, + background: true, + title: automation.name?.substring(0, 100), + }; + this.logService.trace(`[AutomationRunner] running ${automation.id}: target=${target.kind}, provider=${createOptions?.providerId ?? '(default)'}, sessionType=${createOptions?.sessionTypeId ?? '(default)'}, model=${createOptions?.modelId ?? '(default)'}, mode=${createOptions?.modeId ?? '(default)'}, permissionLevel=${createOptions?.permissionLevel ?? '(default)'}`); let session: ISession | undefined; diff --git a/src/vs/sessions/contrib/automations/browser/automationScheduler.ts b/src/vs/sessions/contrib/automations/browser/automationScheduler.ts index 99b8ae166be774..084cbb54c96f12 100644 --- a/src/vs/sessions/contrib/automations/browser/automationScheduler.ts +++ b/src/vs/sessions/contrib/automations/browser/automationScheduler.ts @@ -5,6 +5,7 @@ import { IntervalTimer, raceTimeout } from '../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; import { stringHash } from '../../../../base/common/hash.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; @@ -17,6 +18,7 @@ import { IAutomation } from '../../../../workbench/contrib/chat/common/automatio import { IAutomationRunner } from '../../../../workbench/contrib/chat/common/automations/automationRunner.js'; import { IAutomationService } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; import { CHAT_AUTOMATIONS_ENABLED_SETTING, CHAT_AUTOMATIONS_RUN_TIMEOUT_MINUTES_SETTING, DEFAULT_AUTOMATIONS_RUN_TIMEOUT_MINUTES } from '../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { AutomationLeaderElection, IAutomationLeaderElection } from './automationLeaderElection.js'; export const DEFAULT_SCHEDULER_TICK_MS = 60_000; @@ -33,6 +35,7 @@ export interface IAutomationSchedulerCoreOptions { readonly disableAutoTick?: boolean; readonly isFeatureEnabled?: () => boolean; readonly getRunTimeoutMs?: () => number; + readonly onDidChangeTargetAvailability?: Event; } export class AutomationSchedulerCore extends Disposable { @@ -82,6 +85,11 @@ export class AutomationSchedulerCore extends Disposable { this.kickoffPendingRuns(() => this.tickOnce(false)); }, this._tickIntervalMs); } + if (options.onDidChangeTargetAvailability) { + this._register(options.onDidChangeTargetAvailability(() => { + this.kickoffPendingRuns(() => this.tickOnce(false)); + })); + } } /** Test-only: run a single tick and await it. */ @@ -204,6 +212,7 @@ export class AutomationScheduler extends Disposable implements IWorkbenchContrib @IStorageService private readonly _storageService: IStorageService, @ILogService private readonly _logService: ILogService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, ) { super(); if (this._isEnabled()) { @@ -237,6 +246,7 @@ export class AutomationScheduler extends Disposable implements IWorkbenchContrib : DEFAULT_AUTOMATIONS_RUN_TIMEOUT_MINUTES; return sane * 60_000; }, + onDidChangeTargetAvailability: this._sessionsManagementService.onDidChangeSessionTypes, }); } } diff --git a/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts index 0bec8e2b2eb35c..016ea33c99a620 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts @@ -47,6 +47,8 @@ interface IRecordedCall { class FakeSessionsManagementService extends mock() { readonly calls: IRecordedCall[] = []; + workspaceTargetAvailable = true; + quickChatTargetAvailable = true; /** Configure how the next createAndSendNewChatRequest behaves. */ nextSession: ISession | undefined; @@ -54,6 +56,14 @@ class FakeSessionsManagementService extends mock() { /** Optional hook fired after the call is recorded, before returning/throwing. */ onSendHook: (() => Promise | void) | undefined; + override isNewSessionTargetAvailable(): boolean { + return this.workspaceTargetAvailable; + } + + override isQuickChatTargetAvailable(): boolean { + return this.quickChatTargetAvailable; + } + override async createAndSendNewChatRequest( folderUri: URI, options: ISendRequestOptions, @@ -86,6 +96,15 @@ class FakeSessionsManagementService extends mock() { } } +class RecordingNotificationService extends TestNotificationService { + readonly infos: string[] = []; + + override info(message: string) { + this.infos.push(message); + return super.info(message); + } +} + function fakeSession(id: string, status = observableValue(`status-${id}`, SessionStatus.Completed)): ISession { return upcastPartial({ sessionId: id, @@ -103,8 +122,9 @@ suite('AutomationRunner', () => { const log = new NullLogService(); const service = teardown.add(new AutomationService(storage, log, NullTelemetryService)); const sessionsMgmt = new FakeSessionsManagementService(); - const runner = new AutomationRunner(service, sessionsMgmt, log, NullTelemetryService, new TestNotificationService()); - return { service, sessionsMgmt, runner }; + const notifications = new RecordingNotificationService(); + const runner = new AutomationRunner(service, sessionsMgmt, log, NullTelemetryService, notifications); + return { service, sessionsMgmt, runner, notifications }; } test('creates a session for the automation prompt and marks the run completed', async () => { @@ -264,6 +284,50 @@ suite('AutomationRunner', () => { assert.strictEqual(runs[0].errorMessage, 'provider offline'); }); + test('defers a scheduled run without advancing its schedule when the target is unavailable', async () => { + const { service, sessionsMgmt, runner } = setup(); + sessionsMgmt.workspaceTargetAvailable = false; + const automation = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), target: workspaceTarget() }); + + await runner.runOnce(automation, 'schedule', 1).whenCompleted; + + const updated = service.getAutomation(automation.id); + assert.deepStrictEqual({ + calls: sessionsMgmt.calls.length, + runs: service.runs.get(), + lastRunAt: updated?.lastRunAt, + nextRunAt: updated?.nextRunAt, + }, { + calls: 0, + runs: [], + lastRunAt: undefined, + nextRunAt: automation.nextRunAt, + }); + }); + + test('reports an unavailable target for a manual run without recording a failure', async () => { + const { service, sessionsMgmt, runner, notifications } = setup(); + sessionsMgmt.quickChatTargetAvailable = false; + const automation = await service.createAutomation({ + name: 'Unavailable', + prompt: 'p', + schedule: hourly(), + target: { kind: 'quickChat', providerId: 'local-agent-host', sessionTypeId: 'copilotcli' }, + }); + + await runner.runOnce(automation, 'manual', 1).whenCompleted; + + assert.deepStrictEqual({ + calls: sessionsMgmt.calls.length, + runs: service.runs.get(), + notifications: notifications.infos, + }, { + calls: 0, + runs: [], + notifications: ['Automation \'Unavailable\' cannot start until its agent becomes available.'], + }); + }); + test('skips when another active run exists for the same automation', async () => { const { service, sessionsMgmt, runner } = setup(); diff --git a/src/vs/sessions/contrib/automations/test/browser/automationScheduler.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationScheduler.test.ts index dcaaf4e52ce40c..f061551a72d96e 100644 --- a/src/vs/sessions/contrib/automations/test/browser/automationScheduler.test.ts +++ b/src/vs/sessions/contrib/automations/test/browser/automationScheduler.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { DeferredPromise } from '../../../../../base/common/async.js'; +import { Emitter } from '../../../../../base/common/event.js'; import { IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -218,6 +219,41 @@ suite('AutomationSchedulerCore', () => { }); }); + test('retries a still-due automation when target availability changes', async () => { + const storage = teardown.add(new InMemoryStorageService()); + const log = new NullLogService(); + const service = teardown.add(new AutomationService(storage, log, NullTelemetryService)); + service.setClockForTesting(() => T0); + const automation = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), target: TARGET }); + const runner = new SkippingRunner(); + const leader = new FakeLeaderElection(false); + const onDidChangeTargetAvailability = teardown.add(new Emitter()); + const core = teardown.add(new AutomationSchedulerCore(service, runner, storage, log, { + leaderElection: leader, + disableAutoTick: true, + now: () => T_PAST_DUE, + onDidChangeTargetAvailability: onDidChangeTargetAvailability.event, + })); + + leader.set(true); + await core.waitForPendingRuns(); + onDidChangeTargetAvailability.fire(); + await core.waitForPendingRuns(); + + assert.deepStrictEqual({ + dispatches: runner.runs, + lastRunAt: service.getAutomation(automation.id)?.lastRunAt, + nextRunAt: service.getAutomation(automation.id)?.nextRunAt, + }, { + dispatches: [ + { automationId: automation.id, trigger: 'catch_up' }, + { automationId: automation.id, trigger: 'schedule' }, + ], + lastRunAt: undefined, + nextRunAt: automation.nextRunAt, + }); + }); + test('does nothing while not leader', async () => { const { core, runner, service, leader, setNow } = setup(); setNow(T0); diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index 2362d2acb7cf87..30e7d2e7402174 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -17,6 +17,7 @@ import './nullInlineChatSessionService.js'; import './nullChatTipService.js'; import './modelPicker.js'; import './agentHostDelegation.js'; +import './newSessionFolderQuickPickAction.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ISessionsTasksService, SessionsTasksService } from './sessionsTasksService.js'; diff --git a/src/vs/sessions/contrib/chat/browser/copilotConfigSlashSubmitHandler.ts b/src/vs/sessions/contrib/chat/browser/copilotConfigSlashSubmitHandler.ts new file mode 100644 index 00000000000000..96925bca83c8ec --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/copilotConfigSlashSubmitHandler.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { parseLeadingSlashCommand } from '../../../../platform/agentHost/common/agentHostSlashCommand.js'; +import { resolveCopilotConfigSlashCommandOnSend } from '../../../../platform/agentHost/common/copilotConfigSlashCommands.js'; +import { IChatSubmitRequestHandlerService, type IChatSubmitRequest } from '../../../../workbench/contrib/chat/browser/chatSubmitRequestHandlerService.js'; +import { applyAgentHostCompletionAction } from '../../../../workbench/contrib/chat/browser/agentHostCompletionAction.js'; +import { SessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; + +export class SessionsCopilotConfigSlashSubmitHandlerContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.chat.copilotConfigSlashSubmitHandler'; + + constructor( + @IChatSubmitRequestHandlerService submitRequestHandlerService: IChatSubmitRequestHandlerService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + @IDialogService private readonly _dialogService: IDialogService, + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + this._register(submitRequestHandlerService.register({ + id: 'sessions.copilot.configSlash', + tryHandle: request => this._tryHandle(request), + })); + } + + private async _tryHandle(request: IChatSubmitRequest): Promise { + if (getChatSessionType(request.sessionResource) !== SessionType.AgentHostCopilot) { + return false; + } + const slashCommand = parseLeadingSlashCommand(request.input); + const configAction = slashCommand ? resolveCopilotConfigSlashCommandOnSend(slashCommand.command, slashCommand.rawRest) : undefined; + if (!configAction) { + return false; + } + const session = this._sessionsManagementService.getSession(request.sessionResource); + const providerId = session?.providerId ?? request.providerId; + const sessionId = session?.sessionId ?? request.sessionId; + if (!providerId || !sessionId) { + return false; + } + const provider = this._sessionsProvidersService.getProvider(providerId); + if (!provider || !isAgentHostProvider(provider)) { + return false; + } + await applyAgentHostCompletionAction({ applyConfig: configAction.applyConfig }, this._dialogService, this._storageService, async config => { + await Promise.all(Object.entries(config).map(([key, value]) => provider.setSessionConfigValue(sessionId, key, value))); + }); + return !configAction.strippedPrompt; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 87b2f9b87b1267..4debaf235cca83 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -73,6 +73,7 @@ import { IHistoryNavigationWidget } from '../../../../base/browser/history.js'; import { registerAndCreateHistoryNavigationContext, IHistoryNavigationContext } from '../../../../platform/history/browser/contextScopedHistoryWidget.js'; import { autorun, derived, IObservable, observableValue } from '../../../../base/common/observable.js'; import { ChatInputNotificationWidget } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.js'; +import { IChatSubmitRequestHandlerService } from '../../../../workbench/contrib/chat/browser/chatSubmitRequestHandlerService.js'; import { INewChatModelPickerService, NewChatModelPickerService } from './newChatModelPicker.js'; import { ModelPicker, ModelPickerActionViewItem } from './modelPicker.js'; import { ISessionModelSelectionModel, SessionModelSelectionModel } from './sessionModelSelectionModel.js'; @@ -307,7 +308,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation private readonly _contextAttachments: NewChatContextAttachments; // Slash commands - private _slashCommandHandler: SlashCommandHandler | undefined; private _agentHostInputCompletionHandler: AgentHostInputCompletionHandler | undefined; private readonly _scopedInstantiationService: IInstantiationService; private readonly _newChatModelPickerService = new NewChatModelPickerService(); @@ -358,6 +358,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IChatSpeechToTextService private readonly chatSpeechToTextService: IChatSpeechToTextService, + @IChatSubmitRequestHandlerService private readonly chatSubmitRequestHandlerService: IChatSubmitRequestHandlerService, ) { super(); this._sessionModelSelectionModel = this._register(this.instantiationService.createInstance(SessionModelSelectionModel, this.options.session)); @@ -674,7 +675,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation })); // Slash commands - this._slashCommandHandler = this._register(this._scopedInstantiationService.createInstance(SlashCommandHandler, this._editor)); + this._register(this._scopedInstantiationService.createInstance(SlashCommandHandler, this._editor)); // Variable completions (#file, #folder) this._register(this.instantiationService.createInstance( @@ -952,8 +953,13 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation return; } - // Check for slash commands first - if (query && this._slashCommandHandler?.tryExecuteSlashCommand(query)) { + const session = this.options.session.get(); + if (session && await this.chatSubmitRequestHandlerService.tryHandle({ + sessionResource: session.resource, + providerId: session.providerId, + sessionId: session.sessionId, + input: query, + })) { this._editor.getModel()?.setValue(''); return; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index a23d7e8ac36c29..55eb07fc6fc195 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -12,6 +12,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { localize } from '../../../../nls.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISession } from '../../../services/sessions/common/session.js'; @@ -83,6 +84,7 @@ export class NewChatWidget extends Disposable { @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @IAquariumService private readonly aquariumService: IAquariumService, @IAgentHostFilterService private readonly agentHostFilterService: IAgentHostFilterService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, ) { super(); this._workspacePickerVisibleKey = SessionWorkspacePickerVisibleContext.bindTo(contextKeyService); @@ -156,6 +158,16 @@ export class NewChatWidget extends Disposable { await this._onWorkspaceSelected(this._workspacePicker.selectedFolderUri); this._newChatInput.focus(); })); + + // Re-sync the picker's displayed selection when the session's workspace + // changes externally (e.g. sessionsService.openNewSession({ folderUri })). + this._register(autorun(reader => { + const session = this._session.read(reader); + const folderUri = session?.workspace.read(reader)?.folders[0]?.root; + if (folderUri && !this.uriIdentityService.extUri.isEqual(folderUri, this._workspacePicker.selectedFolderUri)) { + this._workspacePicker.setSelectedWorkspace(folderUri, { fireEvent: false }); + } + })); } // --- Rendering --- diff --git a/src/vs/sessions/contrib/chat/browser/newSessionFolderQuickPickAction.ts b/src/vs/sessions/contrib/chat/browser/newSessionFolderQuickPickAction.ts new file mode 100644 index 00000000000000..749ea1e0a0353c --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/newSessionFolderQuickPickAction.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { URI } from '../../../../base/common/uri.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { ISessionsRecentWorkspacesService } from '../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; + +export interface IFolderQuickPickItem extends IQuickPickItem { + readonly folderUri?: URI; + readonly browse?: boolean; +} + +/** Builds the flat folder quick pick list: own recents, then VS Code recents (deduplicated), then Browse. */ +export function buildFolderQuickPickItems( + recentWorkspacesService: ISessionsRecentWorkspacesService, + labelService: ILabelService, +): (IFolderQuickPickItem | IQuickPickSeparator)[] { + const recents = recentWorkspacesService.getRecentWorkspaces(); + + const items: (IFolderQuickPickItem | IQuickPickSeparator)[] = []; + if (recents.length > 0) { + items.push({ type: 'separator', label: localize('sessions.newSession.pickFolderQuickPick.recent', "Recent") }); + for (const { workspace } of recents) { + const folderUri = workspace.folders[0]?.root; + if (!folderUri) { + continue; + } + items.push({ + folderUri, + label: `$(${workspace.icon.id}) ${workspace.label}`, + description: labelService.getUriLabel(folderUri, { relative: false }), + }); + } + items.push({ type: 'separator', label: '' }); + } + + items.push({ + label: `$(${Codicon.folderOpened.id}) ${localize('sessions.newSession.pickFolderQuickPick.browse', "Browse...")}`, + browse: true, + }); + + return items; +} + +/** Alternative entry point to the new-session workspace picker via {@link IQuickInputService.pick}. */ +class NewSessionPickFolderQuickPickAction extends Action2 { + + constructor() { + super({ + id: 'workbench.action.sessions.newSession.pickFolderQuickPick', + title: localize2('sessions.newSession.pickFolderQuickPick.label', "New Session in Folder..."), + category: CHAT_CATEGORY, + f1: true, + keybinding: { + // Wins over the desktop Open File/Folder actions' Cmd+O when both match. + weight: KeybindingWeight.SessionsContrib, + when: IsSessionsWindowContext, + primary: KeyMod.CtrlCmd | KeyCode.KeyO, + }, + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const quickInputService = accessor.get(IQuickInputService); + const sessionsService = accessor.get(ISessionsService); + const recentWorkspacesService = accessor.get(ISessionsRecentWorkspacesService); + const labelService = accessor.get(ILabelService); + const fileDialogService = accessor.get(IFileDialogService); + + const items = buildFolderQuickPickItems(recentWorkspacesService, labelService); + + const picked = await quickInputService.pick(items, { + placeHolder: localize('sessions.newSession.pickFolderQuickPick.placeholder', "Select a folder to start a new session in"), + matchOnDescription: true, + }); + if (!picked) { + return; + } + + let folderUri = picked.folderUri; + if (picked.browse) { + const result = await fileDialogService.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + }); + folderUri = result?.[0]; + } + if (!folderUri) { + return; + } + + sessionsService.openNewSession({ folderUri }); + } +} + +registerAction2(NewSessionPickFolderQuickPickAction); diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index ecb5c5c49ca8cb..32db059dda01ad 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -11,8 +11,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { disposableTimeout } from '../../../../base/common/async.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { URI, UriComponents } from '../../../../base/common/uri.js'; -import { basename } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; import { autorun } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; @@ -25,27 +24,25 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService, IContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionsRecentWorkspacesService } from '../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; import { SessionWorkspacePickerGroupContext } from '../../../common/contextkeys.js'; // eslint-disable-next-line local/code-import-patterns -- TODO: move remote host options out of providers import { getStatusHover, getStatusLabel, removeRemoteHost, showRemoteHostOptions } from '../../providers/remoteAgentHost/browser/remoteHostOptions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { reportNewChatPickerClosed } from './newChatPickerTelemetry.js'; import { Menus } from '../../../browser/menus.js'; import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; -const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces'; + const FILTER_THRESHOLD = 10; -const MAX_RECENT_WORKSPACES = 10; /** * Fixed picker width when the categorical tab bar is shown. Keeps the tab @@ -63,37 +60,17 @@ const TABBED_PICKER_WIDTH = 360; const RESTORE_CONNECT_GRACE_MS = 5000; /** - * A workspace entry as resolved from a folder URI for rendering. The - * `providerId` is the provider that resolved the URI (first match in - * iteration order). For local URIs that any local provider can resolve, - * this is the first registered local provider; for remote URIs it is the - * remote provider for that authority. - * - * Selection now flows out of the picker as a plain folder URI — the - * provider is rediscovered at session-creation time by - * {@link ISessionsManagementService.createNewSession}. The `providerId` - * carried here is only used internally for rendering (connection state, - * grouping into tabs). + * A workspace as resolved from a folder URI for rendering. The `providerId` + * is the provider that resolved the URI (first match in iteration order, + * or the preferred hint when honored). For local URIs that any local + * provider can resolve, this is the first registered local provider; for + * remote URIs it is the remote provider for that authority. */ export interface IResolvedFolderWorkspace { readonly providerId: string; readonly workspace: ISessionWorkspace; } -/** - * Stored recent workspace entry. The `checked` flag marks the currently - * selected workspace so we only need a single storage key. - * - * `providerId` is retained for backwards compatibility with previously - * stored entries; new entries are written without it. When reading, - * entries are resolved by iterating registered providers. - */ -interface IStoredRecentWorkspace { - readonly uri: UriComponents; - readonly providerId?: string; - readonly checked: boolean; -} - /** * Item type used in the action list. */ @@ -169,9 +146,6 @@ export class WorkspacePicker extends Disposable { */ private _userPickedTab = false; - /** Cached VS Code recent folder URIs, resolved lazily. */ - private _vsCodeRecentFolderUris: URI[] = []; - get selectedFolderUri(): URI | undefined { return this._selectedFolderUri; } @@ -189,13 +163,12 @@ export class WorkspacePicker extends Disposable { constructor( @IActionWidgetService protected readonly actionWidgetService: IActionWidgetService, - @IStorageService private readonly storageService: IStorageService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @ISessionsProvidersService protected readonly sessionsProvidersService: ISessionsProvidersService, + @ISessionsRecentWorkspacesService private readonly recentWorkspacesService: ISessionsRecentWorkspacesService, @IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService, @IConfigurationService private readonly configurationService: IConfigurationService, @ICommandService private readonly commandService: ICommandService, - @IWorkspacesService private readonly workspacesService: IWorkspacesService, @IMenuService private readonly menuService: IMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -254,10 +227,6 @@ export class WorkspacePicker extends Disposable { } })); - // Load VS Code recent folders eagerly and refresh on changes - this._loadVSCodeRecentFolders(); - this._register(this.workspacesService.onDidChangeRecentlyOpened(() => this._loadVSCodeRecentFolders())); - // Re-arm auto-tab whenever the workspace selection changes to a new // value, but only while the picker is closed. This way picking a tab // and then a workspace within the same open keeps that tab active for @@ -608,10 +577,7 @@ export class WorkspacePicker extends Disposable { this._connectionStatusWatch.clear(); this._selectedFolderUri = undefined; this._selectedResolved = undefined; - // Clear checked state from all recents - const recents = this._getStoredRecentWorkspaces(); - const updated = recents.map(p => ({ ...p, checked: false })); - this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE); + this.recentWorkspacesService.clearCheckedWorkspace(); this._updateTriggerLabel(); this._onDidChangeSelection.fire(); } @@ -633,13 +599,13 @@ export class WorkspacePicker extends Disposable { // providerId stored in the recents for this URI, so re-picking a // Local Agent Host folder restores the Local Agent Host association // even when another provider also resolves the URI. - const storedProviderId = this._getStoredRecentWorkspaces() - .find(r => this.uriIdentityService.extUri.isEqual(URI.revive(r.uri), folderUri)) + const storedProviderId = this.recentWorkspacesService.getRecentWorkspaces() + .find(r => this.uriIdentityService.extUri.isEqual(r.workspace.folders[0]?.root, folderUri)) ?.providerId; const resolved = this._resolveFolder(folderUri, providerIdHint ?? storedProviderId); this._selectedFolderUri = folderUri; this._selectedResolved = resolved; - this._persistSelectedFolder(folderUri, resolved?.providerId); + this.recentWorkspacesService.addRecentWorkspace(folderUri, resolved?.providerId, true); this._updateTriggerLabel(); this._onDidChangeSelection.fire(); if (fireEvent) { @@ -778,21 +744,13 @@ export class WorkspacePicker extends Disposable { const tabFilter = this._isTabFiltered() ? (w: IResolvedFolderWorkspace) => w.workspace.group === this._activeTab : undefined; - const ownRecentWorkspaces = this._getRecentWorkspaces() - .filter(w => providerIds.has(w.providerId)) - .filter(w => !tabFilter || tabFilter(w)); - - // Merge VS Code recent folders (resolved through providers, deduplicated) - const vsCodeRecents = this._getVSCodeRecentWorkspaces() + // Own recents first, then VS Code recents (merged and deduplicated by the service) + const recentWorkspaces = this._getRecentWorkspaces() .filter(w => providerIds.has(w.providerId)) .filter(w => !tabFilter || tabFilter(w)); - const ownRecentCount = ownRecentWorkspaces.length; - const recentWorkspaces = [...ownRecentWorkspaces, ...vsCodeRecents]; // Build flat list in recency order (no source grouping) - for (let i = 0; i < recentWorkspaces.length; i++) { - const { workspace, providerId } = recentWorkspaces[i]; - const isOwnRecent = i < ownRecentCount; + for (const { workspace, providerId } of recentWorkspaces) { const folderUri = workspace.folders[0]?.root; if (!folderUri) { continue; @@ -805,7 +763,7 @@ export class WorkspacePicker extends Disposable { group: { title: '', icon: workspace.icon }, disabled: this._isProviderUnavailable(providerId), item: { folderUri, providerId, checked: selected || undefined }, - onRemove: isOwnRecent ? () => this._removeRecentWorkspace(folderUri) : () => this._removeVSCodeRecentWorkspace(folderUri), + onRemove: () => this._removeRecentWorkspace(folderUri), }); } @@ -1004,10 +962,6 @@ export class WorkspacePicker extends Disposable { return this.uriIdentityService.extUri.isEqual(this._selectedFolderUri, folderUri); } - private _persistSelectedFolder(folderUri: URI, providerId: string | undefined): void { - this._addRecentFolder(folderUri, providerId, true); - } - private _restoreSelectedWorkspace(): IResolvedFolderWorkspace | undefined { // Try the checked entry first const checked = this._restoreCheckedWorkspace(); @@ -1020,17 +974,11 @@ export class WorkspacePicker extends Disposable { // to be ready: we don't want to silently land on, e.g., a disconnected // remote workspace that the user never picked. try { - const storedRecents = this._getStoredRecentWorkspaces(); - for (const stored of storedRecents) { - const uri = URI.revive(stored.uri); - const resolved = this._resolveFolder(uri, stored.providerId); - if (!resolved) { - continue; - } - if (this._isProviderUnavailable(resolved.providerId)) { + for (const recent of this.recentWorkspacesService.getRecentWorkspaces()) { + if (this._isProviderUnavailable(recent.providerId)) { continue; } - return resolved; + return recent; } return undefined; } catch { @@ -1048,18 +996,7 @@ export class WorkspacePicker extends Disposable { */ private _restoreCheckedWorkspace(): IResolvedFolderWorkspace | undefined { try { - const storedRecents = this._getStoredRecentWorkspaces(); - for (const stored of storedRecents) { - if (!stored.checked) { - continue; - } - const uri = URI.revive(stored.uri); - const resolved = this._resolveFolder(uri, stored.providerId); - if (resolved) { - return resolved; - } - } - return undefined; + return this.recentWorkspacesService.getRecentWorkspaces().find(recent => recent.checked); } catch { return undefined; } @@ -1130,55 +1067,14 @@ export class WorkspacePicker extends Disposable { }, RESTORE_CONNECT_GRACE_MS, store); } - // -- Recent workspaces storage -- - - private _addRecentFolder(folderUri: URI, providerId: string | undefined, checked: boolean): void { - const recents = this._getStoredRecentWorkspaces(); - const filtered = recents.map(p => { - // Remove the entry being re-added (it will go to the front) - if (this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), folderUri)) { - return undefined; - } - // Clear checked from all other entries when marking checked - if (checked && p.checked) { - return { ...p, checked: false }; - } - return p; - }).filter((p): p is IStoredRecentWorkspace => p !== undefined); - - const entry: IStoredRecentWorkspace = { uri: folderUri.toJSON(), providerId, checked }; - const updated = [entry, ...filtered].slice(0, MAX_RECENT_WORKSPACES); - this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE); - } + // -- Recent workspaces (sessions' own history) -- protected _getRecentWorkspaces(): IResolvedFolderWorkspace[] { - return this._getStoredRecentWorkspaces() - .map(stored => { - const uri = URI.revive(stored.uri); - return this._resolveFolder(uri, stored.providerId); - }) - .filter((w): w is IResolvedFolderWorkspace => w !== undefined); + return this.recentWorkspacesService.getRecentWorkspaces(); } protected _removeRecentWorkspace(folderUri: URI): void { - const recents = this._getStoredRecentWorkspaces(); - const updated = recents.filter(p => - !this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), folderUri) - ); - this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE); - - // Clear current selection if it was the removed workspace - if (this._isSelectedFolder(folderUri)) { - this._hidePicker(); - this._selectedFolderUri = undefined; - this._selectedResolved = undefined; - this._updateTriggerLabel(); - this._onDidSelectWorkspace.fire(undefined); - } - } - - protected _removeVSCodeRecentWorkspace(folderUri: URI): void { - this.workspacesService.removeRecentlyOpened([folderUri]); + this.recentWorkspacesService.removeRecentWorkspace(folderUri); // Clear current selection if it was the removed workspace if (this._isSelectedFolder(folderUri)) { @@ -1190,67 +1086,4 @@ export class WorkspacePicker extends Disposable { } } - private _getStoredRecentWorkspaces(): IStoredRecentWorkspace[] { - const raw = this.storageService.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE); - if (!raw) { - return []; - } - try { - return JSON.parse(raw) as IStoredRecentWorkspace[]; - } catch { - return []; - } - } - - // -- VS Code recent folders ----------------------------------------------- - - private async _loadVSCodeRecentFolders(): Promise { - const recentlyOpened = await this.workspacesService.getRecentlyOpened(); - this._vsCodeRecentFolderUris = recentlyOpened.workspaces - .filter(isRecentFolder) - .map(f => f.folderUri) - .filter(uri => !this._isCopilotWorktree(uri)) - .slice(0, 10); - } - - /** - * Returns whether the given URI points to a copilot-managed folder - * (a folder whose name starts with `copilot-`). - */ - private _isCopilotWorktree(uri: URI): boolean { - return basename(uri).startsWith('copilot-'); - } - - /** - * Returns VS Code recent folders resolved through registered session - * providers, excluding any URIs already present in the sessions' own - * recent workspace history. - */ - protected _getVSCodeRecentWorkspaces(): IResolvedFolderWorkspace[] { - if (this._vsCodeRecentFolderUris.length === 0) { - return []; - } - - // Collect URIs already in sessions history to avoid duplicates - const ownRecents = this._getStoredRecentWorkspaces(); - const ownUris = new Set(ownRecents.map(r => URI.revive(r.uri).toString())); - - const result: IResolvedFolderWorkspace[] = []; - - for (const folderUri of this._vsCodeRecentFolderUris) { - if (ownUris.has(folderUri.toString())) { - continue; - } - const resolved = this._resolveFolder(folderUri); - if (resolved && !this._isProviderUnavailable(resolved.providerId)) { - result.push(resolved); - } - if (result.length >= 10) { - break; - } - } - - return result; - } - } diff --git a/src/vs/sessions/contrib/chat/browser/slashCommands.ts b/src/vs/sessions/contrib/chat/browser/slashCommands.ts index 7895260d3b67f0..458bad33a5cf39 100644 --- a/src/vs/sessions/contrib/chat/browser/slashCommands.ts +++ b/src/vs/sessions/contrib/chat/browser/slashCommands.ts @@ -19,6 +19,7 @@ import { ILanguageFeaturesService } from '../../../../editor/common/services/lan import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; import { localize } from '../../../../nls.js'; import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { IChatSubmitRequestHandlerService, type IChatSubmitRequest, type IChatSubmitRequestHandler } from '../../../../workbench/contrib/chat/browser/chatSubmitRequestHandlerService.js'; import { IChatPromptSlashCommand } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { INewChatModelPickerService } from './newChatModelPicker.js'; import { isAgentHostTarget } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -54,10 +55,11 @@ interface ISessionsSlashCommandData { * Manages slash commands for the sessions new-chat input widget — registration, * autocompletion, decorations (syntax highlighting + placeholder text), and execution. */ -export class SlashCommandHandler extends Disposable { +export class SlashCommandHandler extends Disposable implements IChatSubmitRequestHandler { private static readonly _commandClassName = 'sessions-slash-command'; private static readonly _placeholderClassName = 'sessions-slash-placeholder'; + readonly id = 'sessions.slashCommands'; private readonly _slashCommands: ISessionsSlashCommandData[] = []; private _cachedPromptCommands: readonly IChatPromptSlashCommand[] = []; @@ -73,11 +75,13 @@ export class SlashCommandHandler extends Disposable { @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @INewChatModelPickerService private readonly newChatModelPickerService: INewChatModelPickerService, @ISessionContext private readonly sessionContext: ISessionContext, + @IChatSubmitRequestHandlerService submitRequestHandlerService: IChatSubmitRequestHandlerService, ) { super(); this._commandDecorations = this._editor.createDecorationsCollection(); this._placeholderDecorations = this._editor.createDecorationsCollection(); this._registerSlashCommands(); + this._register(submitRequestHandlerService.register(this)); this._registerCompletions(); this._registerDecorations(); @@ -97,6 +101,14 @@ export class SlashCommandHandler extends Disposable { this._editor.getModel()?.setValue(''); } + async tryHandle(request: IChatSubmitRequest): Promise { + const currentSessionResource = this.sessionContext.session.get()?.resource; + if (!currentSessionResource || !request.providerId || !request.sessionId || !isEqual(currentSessionResource, request.sessionResource)) { + return false; + } + return this.tryExecuteSlashCommand(request.input); + } + private _refreshPromptCommands(sessionResource: URI | undefined): void { const refreshGeneration = ++this._promptCommandsRefreshGeneration; if (!sessionResource) { diff --git a/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts index ca6c35c5a3bc49..9a7ea8f5195587 100644 --- a/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/webWorkspacePicker.ts @@ -15,12 +15,11 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; -import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { IWorkspacesService } from '../../../../platform/workspaces/common/workspaces.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionsRecentWorkspacesService } from '../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; import { IAgentHostFilterService } from '../../../services/agentHostFilter/common/agentHostFilter.js'; import { IWorkspacePickerItem, WorkspacePicker } from './sessionWorkspacePicker.js'; import { showMobileWorkspacePickerSheet, shouldUseMobileWorkspacePickerSheet } from './mobile/mobileWorkspacePickerSheet.js'; @@ -47,13 +46,12 @@ export class WebWorkspacePicker extends WorkspacePicker { constructor( @IActionWidgetService actionWidgetService: IActionWidgetService, - @IStorageService storageService: IStorageService, @IUriIdentityService uriIdentityService: IUriIdentityService, @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + @ISessionsRecentWorkspacesService recentWorkspacesService: ISessionsRecentWorkspacesService, @IRemoteAgentHostService remoteAgentHostService: IRemoteAgentHostService, @IConfigurationService configurationService: IConfigurationService, @ICommandService commandService: ICommandService, - @IWorkspacesService workspacesService: IWorkspacesService, @IMenuService menuService: IMenuService, @IContextKeyService contextKeyService: IContextKeyService, @IInstantiationService instantiationService: IInstantiationService, @@ -65,13 +63,12 @@ export class WebWorkspacePicker extends WorkspacePicker { ) { super( actionWidgetService, - storageService, uriIdentityService, sessionsProvidersService, + recentWorkspacesService, remoteAgentHostService, configurationService, commandService, - workspacesService, menuService, contextKeyService, instantiationService, diff --git a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts index a4f51d27c0c237..bff94fc82c3140 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -20,6 +20,7 @@ import { SessionsView, SessionsViewId as SessionsListViewId } from '../../sessio import { ISessionsSetUpService } from '../../../browser/sessionsSetUpService.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { SessionStatus } from '../../../services/sessions/common/session.js'; +import { SessionsCopilotConfigSlashSubmitHandlerContribution } from '../browser/copilotConfigSlashSubmitHandler.js'; class SelectAgentsFolderContribution extends Disposable implements IWorkbenchContribution { @@ -159,6 +160,7 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon } registerWorkbenchContribution2(SelectAgentsFolderContribution.ID, SelectAgentsFolderContribution, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(SessionsCopilotConfigSlashSubmitHandlerContribution.ID, SessionsCopilotConfigSlashSubmitHandlerContribution, WorkbenchPhase.AfterRestored); // Renderer-side BYOK language-model handler that backs the node agent host's // OpenAI proxy, mirroring the registration in the workbench's diff --git a/src/vs/sessions/contrib/chat/test/browser/newSessionFolderQuickPickAction.test.ts b/src/vs/sessions/contrib/chat/test/browser/newSessionFolderQuickPickAction.test.ts new file mode 100644 index 00000000000000..9d20e264c88077 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/newSessionFolderQuickPickAction.test.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Event } from '../../../../../base/common/event.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; +import { ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { IRecentWorkspace, ISessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; +import { buildFolderQuickPickItems, IFolderQuickPickItem } from '../../browser/newSessionFolderQuickPickAction.js'; + +function isSeparator(item: IFolderQuickPickItem | IQuickPickSeparator): item is IQuickPickSeparator { + return (item as IQuickPickSeparator).type === 'separator'; +} + +function createResolvedRecent(uri: URI, providerId = 'local-1', checked = false): IRecentWorkspace { + const name = uri.path.substring(1) || uri.path; + const workspace: ISessionWorkspace = { + uri, + label: name, + icon: Codicon.folder, + folders: [{ root: uri, workingDirectory: uri, name, description: undefined }], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }; + return { workspace, providerId, checked }; +} + +function createRecentWorkspacesService(recent: IRecentWorkspace[]): ISessionsRecentWorkspacesService { + return upcastPartial({ + _serviceBrand: undefined, + onDidChangeRecentWorkspaces: Event.None, + getRecentWorkspaces: () => recent, + }); +} + +const labelService = upcastPartial({ getUriLabel: (uri: URI) => uri.fsPath }); + +suite('New Session Folder Quick Pick', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('lists the sessions\' own recents followed by VS Code\'s recents, then a Browse entry', () => { + const ownRecentUri = URI.file('/repo-a'); + const vsCodeRecentUri = URI.file('/repo-b'); + + const recentWorkspacesService = createRecentWorkspacesService([ + createResolvedRecent(ownRecentUri), + createResolvedRecent(vsCodeRecentUri), + ]); + + const items = buildFolderQuickPickItems(recentWorkspacesService, labelService); + + const folderItems = items.filter((i): i is IFolderQuickPickItem => !isSeparator(i) && !i.browse); + assert.deepStrictEqual(folderItems.map(i => i.folderUri?.toString()), [ownRecentUri.toString(), vsCodeRecentUri.toString()]); + + const separators = items.filter(isSeparator); + assert.strictEqual(separators.length, 2); + + const lastItem = items[items.length - 1]; + assert.ok(!isSeparator(lastItem) && lastItem.browse, 'last item is the Browse action'); + }); + + test('always includes the Browse entry, even with no recents', () => { + const recentWorkspacesService = createRecentWorkspacesService([]); + + const items = buildFolderQuickPickItems(recentWorkspacesService, labelService); + + assert.strictEqual(items.length, 1); + assert.ok(!isSeparator(items[0]) && items[0].browse, 'the single item is the Browse action'); + }); +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 89cf6a93a7a59a..4dfda32339cdb4 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -32,6 +32,7 @@ import { ISendRequestOptions, ISessionsProvider } from '../../../../services/ses import { IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js'; import { ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; import { IWorkspacePickerItem, WorkspacePicker } from '../../browser/sessionWorkspacePicker.js'; +import { ISessionsRecentWorkspacesService, SessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; import { AutomationsWorkspacePicker } from '../../../automations/browser/automationDialog.js'; import { AutomationIsolationModel } from '../../../automations/common/isolationGroupModel.js'; import { buildMobileWorkspacePickerRows, showMobileWorkspacePickerSheet } from '../../browser/mobile/mobileWorkspacePickerSheet.js'; @@ -173,6 +174,23 @@ class MockSessionsProvidersService extends Disposable { getProvider(providerId: string): T | undefined { return this._providers.find(p => p.id === providerId) as T | undefined; } + + resolveWorkspace(folderUri: URI, preferredProviderId?: string) { + if (preferredProviderId) { + const preferred = this.getProvider(preferredProviderId); + const workspace = preferred?.resolveWorkspace(folderUri); + if (workspace) { + return { providerId: preferredProviderId, workspace }; + } + } + for (const provider of this.getProviders()) { + const workspace = provider.resolveWorkspace(folderUri); + if (workspace) { + return { providerId: provider.id, workspace }; + } + } + return undefined; + } } class RecordingNotificationHandle extends NoOpNotification { @@ -278,6 +296,7 @@ function createTestPicker( getRecentlyOpened: async () => ({ workspaces: [], files: [] }), onDidChangeRecentlyOpened: Event.None, }); + instantiationService.stub(ISessionsRecentWorkspacesService, disposables.add(instantiationService.createInstance(SessionsRecentWorkspacesService))); instantiationService.stub(ITelemetryService, NullTelemetryService); return disposables.add(instantiationService.createInstance(pickerCtor)); @@ -940,6 +959,7 @@ function createTestablePicker(disposables: DisposableStore, providersService: Mo getRecentlyOpened: async () => ({ workspaces: [], files: [] }), onDidChangeRecentlyOpened: Event.None, }); + instantiationService.stub(ISessionsRecentWorkspacesService, disposables.add(instantiationService.createInstance(SessionsRecentWorkspacesService))); instantiationService.stub(ITelemetryService, NullTelemetryService); return disposables.add(instantiationService.createInstance(TestablePicker)); } @@ -1053,6 +1073,7 @@ suite('WorkspacePicker - Tab discovery', () => { getRecentlyOpened: async () => ({ workspaces: [], files: [] }), onDidChangeRecentlyOpened: Event.None, }); + instantiationService.stub(ISessionsRecentWorkspacesService, disposables.add(instantiationService.createInstance(SessionsRecentWorkspacesService))); instantiationService.stub(ITelemetryService, NullTelemetryService); const picker = disposables.add(instantiationService.createInstance(TestablePicker)); // Recent workspace group ('Cloud') is not added as a tab — only diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 51611a0f248bda..6775256c107f32 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -5,7 +5,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { raceCancellationError } from '../../../../base/common/async.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; @@ -74,6 +74,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa readonly newSession: IObservable = this._newSession; private readonly _providerListeners = this._register(new DisposableMap()); + private readonly _disposeCts = this._register(new CancellationTokenSource()); /** * Chat resources for which this service has just kicked off a @@ -239,6 +240,21 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return result; } + isNewSessionTargetAvailable(folderUri: URI, options?: ICreateNewSessionOptions): boolean { + return this._isTargetAvailable(this.getSessionTypesForFolder(folderUri), options); + } + + isQuickChatTargetAvailable(options?: ICreateNewSessionOptions): boolean { + return this._isTargetAvailable(this.getQuickChatSessionTypes(), options); + } + + private _isTargetAvailable(sessionTypes: readonly IProviderSessionType[], options?: ICreateNewSessionOptions): boolean { + return sessionTypes.some(candidate => + (!options?.providerId || candidate.providerId === options.providerId) + && (!options?.sessionTypeId || candidate.sessionType.id === options.sessionTypeId) + ); + } + resolveWorkspace(folderUri: URI): { providerId: string; workspace: ISessionWorkspace } | undefined { for (const provider of this.sessionsProvidersService.getProviders()) { const workspace = provider.resolveWorkspace(folderUri); @@ -306,6 +322,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa if (!provider.resolveWorkspace(folderUri)) { throw new Error(`Sessions provider '${options.providerId}' cannot resolve folder '${folderUri.toString()}'`); } + if (options.sessionTypeId && !provider.getSessionTypes(folderUri).some(type => type.id === options.sessionTypeId)) { + throw new Error(`Sessions provider '${options.providerId}' does not advertise session type '${options.sessionTypeId}'`); + } } else { // Iterate providers and pick the first one that can resolve the folder. // When a specific session type was requested, also require the provider to @@ -580,7 +599,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const session = provider.createNewSession(folderUri, sessionTypeId); const supportsWorktreeConfiguration = provider.getSessionTypes(folderUri) .find(sessionType => sessionType.id === sessionTypeId)?.supportsWorktreeConfiguration === true; - return this._configureAndSendNewSession(provider, session, options, createOptions, supportsWorktreeConfiguration, token); + return this._configureAndSendNewSession(provider, session, options, createOptions, supportsWorktreeConfiguration, token, folderUri); } async createAndSendQuickChatRequest(options: ISendRequestOptions, createOptions?: ICreateNewSessionOptions, token: CancellationToken = CancellationToken.None): Promise { @@ -596,12 +615,14 @@ export class SessionsManagementService extends Disposable implements ISessionsMa createOptions: ICreateNewSessionOptions | undefined, supportsWorktreeConfiguration: boolean, token: CancellationToken, + folderUri?: URI, ): Promise { try { if (token.isCancellationRequested) { throw new CancellationError(); } if (createOptions?.modelId) { + await this._waitForRequestedModel(provider, session, createOptions.modelId, token, folderUri); provider.setModel(session.sessionId, createOptions.modelId); } if (createOptions?.modeId) { @@ -632,6 +653,65 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } } + private async _waitForRequestedModel(provider: ISessionsProvider, session: ISession, modelId: string, token: CancellationToken, folderUri?: URI): Promise { + const resolveCurrent = () => provider.getModelsSnapshot(session.sessionId, modelId).desiredModelResolution; + const initial = resolveCurrent(); + if (initial.kind === 'available' || initial.kind === 'notRequested') { + return; + } + if (initial.kind === 'unavailable') { + throw new Error(`Model '${modelId}' is unavailable for sessions provider '${provider.id}'`); + } + if (token.isCancellationRequested) { + throw new CancellationError(); + } + + await new Promise((resolve, reject) => { + const disposables = new DisposableStore(); + let settled = false; + const finish = (error?: Error) => { + if (settled) { + return; + } + settled = true; + disposables.dispose(); + if (error) { + reject(error); + } else { + resolve(); + } + }; + const check = () => { + const resolution = resolveCurrent(); + if (resolution.kind === 'available' || resolution.kind === 'notRequested') { + finish(); + } else if (resolution.kind === 'unavailable') { + finish(new Error(`Model '${modelId}' is unavailable for sessions provider '${provider.id}'`)); + } + }; + disposables.add(provider.onDidChangeModels(check)); + disposables.add(provider.onDidChangeSessionTypes(() => { + const sessionTypes = folderUri ? provider.getSessionTypes(folderUri) : provider.sessionTypes; + if (!sessionTypes.some(type => type.id === session.sessionType)) { + finish(new Error(`Session type '${session.sessionType}' is no longer available for sessions provider '${provider.id}'`)); + } + })); + disposables.add(this.sessionsProvidersService.onDidChangeProviders(event => { + if (event.removed.includes(provider)) { + finish(new Error(`Sessions provider '${provider.id}' is no longer available`)); + } + })); + disposables.add(token.onCancellationRequested(() => finish(new CancellationError()))); + disposables.add(this._disposeCts.token.onCancellationRequested(() => finish(new CancellationError()))); + check(); + }); + } + + override dispose(): void { + this._disposeCts.cancel(); + super.dispose(); + } + /** * Commit a new-session request: fire {@link _onWillSendRequest}, create the * new chat via the provider, send the request, and—on success—fire diff --git a/src/vs/sessions/services/sessions/browser/sessionsRecentWorkspacesService.ts b/src/vs/sessions/services/sessions/browser/sessionsRecentWorkspacesService.ts new file mode 100644 index 00000000000000..3bfdfc7a0f7821 --- /dev/null +++ b/src/vs/sessions/services/sessions/browser/sessionsRecentWorkspacesService.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { basename } from '../../../../base/common/resources.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { URI, UriComponents } from '../../../../base/common/uri.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { isRecentFolder, IWorkspacesService } from '../../../../platform/workspaces/common/workspaces.js'; +import { ISessionWorkspace } from '../common/session.js'; +import { ISessionsProvidersService } from './sessionsProvidersService.js'; + +const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces'; +const MAX_RECENT_WORKSPACES = 10; +const MAX_VSCODE_RECENT_WORKSPACES = 10; + +/** A recently used folder, resolved to its workspace. `checked` marks the currently selected folder in the new-session workspace picker. */ +export interface IRecentWorkspace { + readonly workspace: ISessionWorkspace; + readonly providerId: string; + readonly checked: boolean; +} + +interface IStoredRecentWorkspace { + readonly uri: UriComponents; + readonly providerId?: string; + readonly checked: boolean; +} + +export const ISessionsRecentWorkspacesService = createDecorator('sessionsRecentWorkspacesService'); + +/** Single source of truth for the sessions' own "recently used" workspace folders, shared by every folder-selection surface. */ +export interface ISessionsRecentWorkspacesService { + readonly _serviceBrand: undefined; + + readonly onDidChangeRecentWorkspaces: Event; + + /** The recently used folders, resolved and most recent first: own history first, then VS Code's recents (deduplicated). */ + getRecentWorkspaces(): IRecentWorkspace[]; + + /** Records `folderUri` as most-recently used; `checked` un-checks every other entry. */ + addRecentWorkspace(folderUri: URI, providerId: string | undefined, checked: boolean): void; + + /** Removes `folderUri` from the recent list, wherever it came from (own history or VS Code's recents). */ + removeRecentWorkspace(folderUri: URI): void; + + /** Clears the `checked` flag on every recent entry. */ + clearCheckedWorkspace(): void; +} + +/** Exported for direct instantiation in tests; consumers should depend on {@link ISessionsRecentWorkspacesService}. */ +export class SessionsRecentWorkspacesService extends Disposable implements ISessionsRecentWorkspacesService { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeRecentWorkspaces = this._register(new Emitter()); + readonly onDidChangeRecentWorkspaces: Event = this._onDidChangeRecentWorkspaces.event; + + private _vsCodeRecentFolderUris: URI[] = []; + + constructor( + @IStorageService private readonly storageService: IStorageService, + @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @IWorkspacesService private readonly workspacesService: IWorkspacesService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + + this._refreshVSCodeRecentWorkspaces(); + this._register(this.workspacesService.onDidChangeRecentlyOpened(() => this._refreshVSCodeRecentWorkspaces())); + } + + getRecentWorkspaces(): IRecentWorkspace[] { + const own = this._getStoredRecentWorkspaces(); + const ownUris = new Set(own.map(o => this.uriIdentityService.extUri.getComparisonKey(URI.revive(o.uri)))); + const vsCode = this._vsCodeRecentFolderUris + .filter(uri => !ownUris.has(this.uriIdentityService.extUri.getComparisonKey(uri))) + .map(uri => ({ uri: uri.toJSON(), providerId: undefined, checked: false }) satisfies IStoredRecentWorkspace); + + const recents: IRecentWorkspace[] = []; + for (const stored of [...own, ...vsCode]) { + const resolved = this._resolveWorkspace(URI.revive(stored.uri), stored.providerId); + if (resolved) { + recents.push({ workspace: resolved.workspace, providerId: resolved.providerId, checked: stored.checked }); + } + } + return recents; + } + + addRecentWorkspace(folderUri: URI, providerId: string | undefined, checked: boolean): void { + const recents = this._getStoredRecentWorkspaces(); + const filtered = recents.map(p => { + // Remove the entry being re-added (it will go to the front) + if (this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), folderUri)) { + return undefined; + } + // Clear checked from all other entries when marking checked + if (checked && p.checked) { + return { ...p, checked: false }; + } + return p; + }).filter((p): p is IStoredRecentWorkspace => p !== undefined); + + const entry: IStoredRecentWorkspace = { uri: folderUri.toJSON(), providerId, checked }; + const updated = [entry, ...filtered].slice(0, MAX_RECENT_WORKSPACES); + this._persistRecentWorkspaces(updated); + } + + removeRecentWorkspace(folderUri: URI): void { + const recents = this._getStoredRecentWorkspaces(); + const updated = recents.filter(p => !this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), folderUri)); + if (updated.length !== recents.length) { + this._persistRecentWorkspaces(updated); + } + this.workspacesService.removeRecentlyOpened([folderUri]); + } + + clearCheckedWorkspace(): void { + const recents = this._getStoredRecentWorkspaces(); + const updated = recents.map(p => ({ ...p, checked: false })); + this._persistRecentWorkspaces(updated); + } + + /** Resolves `folderUri` to its workspace, trying `preferredProviderId` first if given. */ + private _resolveWorkspace(folderUri: URI, preferredProviderId?: string): { providerId: string; workspace: ISessionWorkspace } | undefined { + if (preferredProviderId) { + const preferred = this.sessionsProvidersService.getProvider(preferredProviderId); + const workspace = preferred?.resolveWorkspace(folderUri); + if (workspace) { + return { providerId: preferredProviderId, workspace }; + } + } + for (const provider of this.sessionsProvidersService.getProviders()) { + const workspace = provider.resolveWorkspace(folderUri); + if (workspace) { + return { providerId: provider.id, workspace }; + } + } + return undefined; + } + + private async _refreshVSCodeRecentWorkspaces(): Promise { + const recentlyOpened = await this.workspacesService.getRecentlyOpened(); + this._vsCodeRecentFolderUris = recentlyOpened.workspaces + .filter(isRecentFolder) + .map(f => f.folderUri) + .filter(uri => !basename(uri).startsWith('copilot-')) + .slice(0, MAX_VSCODE_RECENT_WORKSPACES); + this._onDidChangeRecentWorkspaces.fire(); + } + + private _getStoredRecentWorkspaces(): IStoredRecentWorkspace[] { + const raw = this.storageService.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE); + if (!raw) { + return []; + } + try { + return JSON.parse(raw) as IStoredRecentWorkspace[]; + } catch { + return []; + } + } + + private _persistRecentWorkspaces(entries: IStoredRecentWorkspace[]): void { + this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(entries), StorageScope.PROFILE, StorageTarget.MACHINE); + this._onDidChangeRecentWorkspaces.fire(); + } +} + +registerSingleton(ISessionsRecentWorkspacesService, SessionsRecentWorkspacesService, InstantiationType.Delayed); diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index b5f697a753de91..232bdf8265660c 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -225,6 +225,12 @@ export interface ISessionsManagementService { */ getQuickChatSessionTypes(): IProviderSessionType[]; + /** Whether the requested workspace session target is currently advertised. */ + isNewSessionTargetAvailable(folderUri: URI, options?: ICreateNewSessionOptions): boolean; + + /** Whether the requested quick-chat target is currently advertised. */ + isQuickChatTargetAvailable(options?: ICreateNewSessionOptions): boolean; + /** * Resolve a workspace URI to a workspace using the first provider whose * {@link ISessionsProvider.resolveWorkspace} succeeds. Returns `undefined` diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index c2f0829cb8e5e6..4d8c64e5e37e6f 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -171,6 +171,8 @@ class MockSessionStore implements ISessionsManagementService { getAllSessionTypes(): ISessionType[] { return []; } getSessionTypesForFolder(_folderUri: URI): IProviderSessionType[] { return []; } getQuickChatSessionTypes(): IProviderSessionType[] { return []; } + isNewSessionTargetAvailable(_folderUri: URI, _options?: ICreateNewSessionOptions): boolean { return false; } + isQuickChatTargetAvailable(_options?: ICreateNewSessionOptions): boolean { return false; } resolveWorkspace(_folderUri: URI): { providerId: string; workspace: ISessionWorkspace } | undefined { return undefined; } async openSession(sessionResource: URI): Promise { diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 3e79bca2e1ba99..f462ce119a477e 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -25,9 +25,10 @@ import { IChatService } from '../../../../../workbench/contrib/chat/common/chatS import { IChatEditorOptions } from '../../../../../workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.js'; import { IChatWidgetHistoryService } from '../../../../../workbench/contrib/chat/common/widget/chatWidgetHistoryService.js'; import { PreferredGroup } from '../../../../../workbench/services/editor/common/editorService.js'; +import { nullExtensionDescription } from '../../../../../workbench/services/extensions/common/extensions.js'; import { ChatInteractivity, IChat, ISession, ISessionType, ISessionWorkspace, SessionStatus } from '../../common/session.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../../workbench/contrib/chat/common/languageModels.js'; -import { ISessionChangeEvent, ISendRequestOptions, ISessionModelPickerOptions, ISessionsProvider } from '../../common/sessionsProvider.js'; +import { ISessionChangeEvent, ISendRequestOptions, ISessionModelsSnapshot, ISessionModelPickerOptions, ISessionsProvider } from '../../common/sessionsProvider.js'; import { SessionsManagementService } from '../../browser/sessionsManagementService.js'; import { ISessionsManagementService, ICreateNewSessionOptions } from '../../common/sessionsManagement.js'; import { SessionsService } from '../../browser/sessionsService.js'; @@ -154,11 +155,11 @@ class TestSessionsProvider extends mock() { } override getSessions(): ISession[] { return [this._session]; } - override resolveWorkspace(): ISessionWorkspace | undefined { return undefined; } + override resolveWorkspace(_folderUri: URI): ISessionWorkspace | undefined { return undefined; } override createNewSession(): ISession { return this._session; } - override getSessionTypes(): ISessionType[] { return [...this.sessionTypes]; } + override getSessionTypes(_folderUri: URI): ISessionType[] { return [...this.sessionTypes]; } override async renameChat(): Promise { } - override getModelsSnapshot(): { models: readonly ILanguageModelChatMetadataAndIdentifier[]; desiredModelResolution: { kind: 'notRequested' }; modelTarget: undefined } { return { models: [], desiredModelResolution: { kind: 'notRequested' }, modelTarget: undefined }; } + override getModelsSnapshot(): ISessionModelsSnapshot { return { models: [], desiredModelResolution: { kind: 'notRequested' }, modelTarget: undefined }; } override getModelPickerOptions(): ISessionModelPickerOptions { return { useGroupedModelPicker: true, showFeatured: true, showUnavailableFeatured: false, showManageModelsAction: false }; } override readonly onDidChangeModels = Event.None; override setModel(_sessionId: string, _modelId: string): void { } @@ -874,6 +875,56 @@ suite('SessionsManagementService', () => { assert.strictEqual(view.activeSession.get(), undefined); }); + test('target availability requires the requested provider and session type to be advertised', () => { + const session = stubSession({ sessionId: 's1', providerId: 'test' }); + const availableFolder = URI.parse('test:///available'); + const provider = new class extends TestSessionsProvider { + override readonly supportsQuickChats = true; + override readonly sessionTypes: readonly ISessionType[] = [ + { id: 'workspace-agent', label: 'Workspace Agent', icon: Codicon.vm }, + { id: 'quick-agent', label: 'Quick Agent', icon: Codicon.vm }, + ]; + override resolveWorkspace(folderUri: URI): ISessionWorkspace | undefined { + return extUriBiasedIgnorePathCase.isEqual(folderUri, availableFolder) ? { folderUri } as unknown as ISessionWorkspace : undefined; + } + override getSessionTypes(folderUri: URI): ISessionType[] { + return extUriBiasedIgnorePathCase.isEqual(folderUri, availableFolder) ? [this.sessionTypes[0]] : []; + } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + + assert.deepStrictEqual({ + defaultWorkspace: service.isNewSessionTargetAvailable(availableFolder), + exactWorkspace: service.isNewSessionTargetAvailable(availableFolder, { providerId: 'test', sessionTypeId: 'workspace-agent' }), + wrongWorkspaceType: service.isNewSessionTargetAvailable(availableFolder, { providerId: 'test', sessionTypeId: 'quick-agent' }), + missingWorkspace: service.isNewSessionTargetAvailable(URI.parse('test:///missing')), + exactQuickChat: service.isQuickChatTargetAvailable({ providerId: 'test', sessionTypeId: 'quick-agent' }), + wrongQuickChatProvider: service.isQuickChatTargetAvailable({ providerId: 'other', sessionTypeId: 'quick-agent' }), + }, { + defaultWorkspace: true, + exactWorkspace: true, + wrongWorkspaceType: false, + missingWorkspace: false, + exactQuickChat: true, + wrongQuickChatProvider: false, + }); + }); + + test('createNewSession rejects a pinned session type that is not advertised', () => { + const session = stubSession({ sessionId: 's1', providerId: 'test' }); + const provider = new class extends TestSessionsProvider { + override resolveWorkspace(folderUri: URI): ISessionWorkspace { + return { folderUri } as unknown as ISessionWorkspace; + } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + + assert.throws( + () => service.createNewSession(URI.parse('test:///folder'), { providerId: 'test', sessionTypeId: 'missing' }), + /does not advertise session type 'missing'/, + ); + }); + test('createAndSendQuickChatRequest uses the quick-chat contract without navigation or repository configuration', async () => { const chat: IChat = { ...stubChat, resource: URI.parse('test:///quick-chat') }; const activeSession = stubSession({ sessionId: 'active', providerId: 'test' }); @@ -1018,6 +1069,124 @@ suite('SessionsManagementService', () => { ]); }); + test('createAndSendNewChatRequest waits for an explicit model to become available', async () => { + const session = stubSession({ sessionId: 's1', providerId: 'test' }); + const onDidChangeModels = disposables.add(new Emitter()); + let resolution: ISessionModelsSnapshot['desiredModelResolution'] = { kind: 'pending', identifier: 'gpt-4o' }; + const calls: string[] = []; + const model: ILanguageModelChatMetadataAndIdentifier = { + identifier: 'gpt-4o', + metadata: { + extension: nullExtensionDescription.identifier, + name: 'GPT-4o', + vendor: 'copilot', + family: 'gpt-4o', + version: '1', + id: 'gpt-4o', + maxInputTokens: 100, + maxOutputTokens: 100, + isDefaultForLocation: {}, + }, + }; + const provider = new class extends TestSessionsProvider { + override readonly onDidChangeModels = onDidChangeModels.event; + override resolveWorkspace(folderUri: URI): ISessionWorkspace { return { folderUri } as unknown as ISessionWorkspace; } + override getModelsSnapshot(): ISessionModelsSnapshot { return { models: [], desiredModelResolution: resolution, modelTarget: undefined }; } + override setModel(): void { calls.push('setModel'); } + override async sendRequest(): Promise { + calls.push('send'); + return session; + } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + + const request = service.createAndSendNewChatRequest(URI.parse('test:///folder'), { query: 'hi' }, { modelId: 'gpt-4o' }); + await Promise.resolve(); + assert.deepStrictEqual(calls, []); + + resolution = { kind: 'available', model }; + onDidChangeModels.fire(); + await request; + + assert.deepStrictEqual(calls, ['setModel', 'send']); + }); + + test('createAndSendNewChatRequest rejects a pending model that becomes unavailable and disposes the draft', async () => { + const session = stubSession({ sessionId: 's1', providerId: 'test' }); + const onDidChangeModels = disposables.add(new Emitter()); + let resolution: ISessionModelsSnapshot['desiredModelResolution'] = { kind: 'pending', identifier: 'removed-model' }; + let deleted = false; + const provider = new class extends TestSessionsProvider { + override readonly onDidChangeModels = onDidChangeModels.event; + override resolveWorkspace(folderUri: URI): ISessionWorkspace { return { folderUri } as unknown as ISessionWorkspace; } + override getModelsSnapshot(): ISessionModelsSnapshot { + return { models: [], desiredModelResolution: resolution, modelTarget: undefined }; + } + override setModel(): never { throw new Error('setModel should not be called'); } + override deleteNewSession(): void { deleted = true; } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + + const request = service.createAndSendNewChatRequest(URI.parse('test:///folder'), { query: 'hi' }, { modelId: 'removed-model' }); + await Promise.resolve(); + resolution = { kind: 'unavailable', identifier: 'removed-model' }; + onDidChangeModels.fire(); + + await assert.rejects(request, /Model 'removed-model' is unavailable/); + assert.strictEqual(deleted, true); + }); + + test('createAndSendNewChatRequest rejects when the workspace stops advertising the session type', async () => { + const folderUri = URI.parse('test:///folder'); + const session = stubSession({ sessionId: 's1', providerId: 'test' }); + const onDidChangeSessionTypes = disposables.add(new Emitter()); + let folderTypeAvailable = true; + let deleted = false; + const provider = new class extends TestSessionsProvider { + override readonly onDidChangeSessionTypes = onDidChangeSessionTypes.event; + override resolveWorkspace(): ISessionWorkspace { return { uri: folderUri } as ISessionWorkspace; } + override getSessionTypes(candidate: URI): ISessionType[] { + return folderTypeAvailable && extUriBiasedIgnorePathCase.isEqual(candidate, folderUri) ? [...this.sessionTypes] : []; + } + override getModelsSnapshot(): ISessionModelsSnapshot { + return { models: [], desiredModelResolution: { kind: 'pending', identifier: 'gpt-4o' }, modelTarget: undefined }; + } + override deleteNewSession(): void { deleted = true; } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + + const request = service.createAndSendNewChatRequest(folderUri, { query: 'hi' }, { modelId: 'gpt-4o' }); + await Promise.resolve(); + folderTypeAvailable = false; + onDidChangeSessionTypes.fire(); + + await assert.rejects(request, /Session type 'test' is no longer available/); + assert.strictEqual(deleted, true); + }); + + test('createAndSendNewChatRequest cancels while waiting for model resolution and disposes the draft', async () => { + const session = stubSession({ sessionId: 's1', providerId: 'test' }); + const onDidChangeModels = disposables.add(new Emitter()); + let deleted = false; + const provider = new class extends TestSessionsProvider { + override readonly onDidChangeModels = onDidChangeModels.event; + override resolveWorkspace(folderUri: URI): ISessionWorkspace { return { folderUri } as unknown as ISessionWorkspace; } + override getModelsSnapshot(): ISessionModelsSnapshot { + return { models: [], desiredModelResolution: { kind: 'pending', identifier: 'gpt-4o' }, modelTarget: undefined }; + } + override deleteNewSession(): void { deleted = true; } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + const cts = disposables.add(new CancellationTokenSource()); + + const request = service.createAndSendNewChatRequest(URI.parse('test:///folder'), { query: 'hi' }, { modelId: 'gpt-4o' }, cts.token); + await Promise.resolve(); + cts.cancel(); + + await assert.rejects(request, /Canceled/); + assert.strictEqual(deleted, true); + }); + test('createAndSendNewChatRequest awaits asynchronous repository configuration setters', async () => { const chat: IChat = { ...stubChat, resource: URI.parse('test:///chat') }; const session = stubSession({ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSubmitConfig.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSubmitConfig.ts new file mode 100644 index 00000000000000..bb14c56617095c --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/applyAgentHostSubmitConfig.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../../base/common/uri.js'; +import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { applyAgentHostCompletionAction } from '../../agentHostCompletionAction.js'; +import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; +import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; +import { applyAgentHostSessionConfigChange } from './applyAgentHostSessionConfig.js'; + +export interface IApplyAgentHostSubmitConfigServices { + readonly agentHostService: IAgentHostService; + readonly provisionalService: IAgentHostUntitledProvisionalSessionService; + readonly workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver; + readonly workspaceContextService: IWorkspaceContextService; + readonly configurationService: IConfigurationService; + readonly dialogService: IDialogService; + readonly storageService: IStorageService; +} + +export async function applyAgentHostSubmitConfig( + sessionResource: URI, + config: Readonly>, + services: IApplyAgentHostSubmitConfigServices, +): Promise { + let applied = false; + const confirmed = await applyAgentHostCompletionAction({ applyConfig: config }, services.dialogService, services.storageService, async config => { + applied = await applyAgentHostSessionConfigChange(sessionResource, config, services); + }); + return confirmed && applied; +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/copilotConfigSlashSubmitHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/copilotConfigSlashSubmitHandler.ts new file mode 100644 index 00000000000000..903af92687e6a9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/copilotConfigSlashSubmitHandler.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { parseLeadingSlashCommand } from '../../../../../../platform/agentHost/common/agentHostSlashCommand.js'; +import { resolveCopilotConfigSlashCommandOnSend } from '../../../../../../platform/agentHost/common/copilotConfigSlashCommands.js'; +import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchContribution } from '../../../../../common/contributions.js'; +import { IChatSubmitRequestHandlerService, type IChatSubmitRequest } from '../../chatSubmitRequestHandlerService.js'; +import { SessionType } from '../../../common/chatSessionsService.js'; +import { getChatSessionType } from '../../../common/model/chatUri.js'; +import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; +import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; +import { applyAgentHostSubmitConfig } from './applyAgentHostSubmitConfig.js'; + +export interface ICopilotConfigSlashSubmitResolution { + readonly applyConfig: Readonly>; + readonly strippedPrompt: string; +} + +/** Resolves a typed Copilot config slash command into a config change. */ +export function resolveCopilotConfigSlashSubmit(input: string): ICopilotConfigSlashSubmitResolution | undefined { + const slashCommand = parseLeadingSlashCommand(input); + return slashCommand ? resolveCopilotConfigSlashCommandOnSend(slashCommand.command, slashCommand.rawRest) : undefined; +} + +export class CopilotConfigSlashSubmitHandlerContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chat.copilotConfigSlashSubmitHandler'; + + constructor( + @IChatSubmitRequestHandlerService submitRequestHandlerService: IChatSubmitRequestHandlerService, + @IAgentHostService private readonly _agentHostService: IAgentHostService, + @IAgentHostUntitledProvisionalSessionService private readonly _provisionalService: IAgentHostUntitledProvisionalSessionService, + @IAgentHostSessionWorkingDirectoryResolver private readonly _workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IDialogService private readonly _dialogService: IDialogService, + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + this._register(submitRequestHandlerService.register({ + id: 'copilot.configSlash', + tryHandle: request => this._tryHandle(request), + })); + } + + private async _tryHandle(request: IChatSubmitRequest): Promise { + if (getChatSessionType(request.sessionResource) !== SessionType.AgentHostCopilot) { + return false; + } + const configAction = resolveCopilotConfigSlashSubmit(request.input); + if (!configAction) { + return false; + } + await applyAgentHostSubmitConfig(request.sessionResource, configAction.applyConfig, { + agentHostService: this._agentHostService, + provisionalService: this._provisionalService, + workingDirectoryResolver: this._workingDirectoryResolver, + workspaceContextService: this._workspaceContextService, + configurationService: this._configurationService, + dialogService: this._dialogService, + storageService: this._storageService, + }); + return !configAction.strippedPrompt; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index fc8e5b32fadc7e..69df191de7c9ce 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -68,6 +68,7 @@ import { ILanguageModelToolsService } from '../common/tools/languageModelToolsSe import { ChatToolRiskAssessmentService, IChatToolRiskAssessmentService } from './tools/chatToolRiskAssessmentService.js'; import { ChatGoalSummaryService, IChatGoalSummaryService } from './chatGoalSummaryService.js'; import { ChatResponseFileChangesService, IChatResponseFileChangesService } from './chatResponseFileChangesService.js'; +import { ChatSubmitRequestHandlerService, IChatSubmitRequestHandlerService } from './chatSubmitRequestHandlerService.js'; import { AgentPluginDiscoveryPriority, agentPluginDiscoveryRegistry, IAgentPluginService } from '../common/plugins/agentPluginService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { isTildePath, PromptsConfig } from '../common/promptSyntax/config/config.js'; @@ -2854,6 +2855,7 @@ registerSingleton(ILanguageModelToolsConfirmationService, LanguageModelToolsConf registerSingleton(IChatToolRiskAssessmentService, ChatToolRiskAssessmentService, InstantiationType.Delayed); registerSingleton(IChatGoalSummaryService, ChatGoalSummaryService, InstantiationType.Delayed); registerSingleton(IChatResponseFileChangesService, ChatResponseFileChangesService, InstantiationType.Delayed); +registerSingleton(IChatSubmitRequestHandlerService, ChatSubmitRequestHandlerService, InstantiationType.Delayed); registerSingleton(IVoiceChatService, VoiceChatService, InstantiationType.Delayed); registerSingleton(IChatCodeBlockContextProviderService, ChatCodeBlockContextProviderService, InstantiationType.Delayed); registerSingleton(ICodeMapperService, CodeMapperService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chatSubmitRequestHandlerService.ts b/src/vs/workbench/contrib/chat/browser/chatSubmitRequestHandlerService.ts new file mode 100644 index 00000000000000..0cfa3b67bc4def --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatSubmitRequestHandlerService.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toDisposable, type IDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const IChatSubmitRequestHandlerService = createDecorator('chatSubmitRequestHandlerService'); + +/** A chat input submission before it is sent to the selected chat session. */ +export interface IChatSubmitRequest { + readonly sessionResource: URI; + readonly providerId?: string; + readonly sessionId?: string; + readonly input: string; +} + +/** Handler offered a chat input submission before the normal send path. */ +export interface IChatSubmitRequestHandler { + readonly id: string; + tryHandle(request: IChatSubmitRequest): Promise; +} + +/** Registry for provider-specific pre-submit chat handlers. */ +export interface IChatSubmitRequestHandlerService { + readonly _serviceBrand: undefined; + register(handler: IChatSubmitRequestHandler): IDisposable; + tryHandle(request: IChatSubmitRequest): Promise; +} + +/** Default sequential first-match implementation of the submit handler registry. */ +export class ChatSubmitRequestHandlerService implements IChatSubmitRequestHandlerService { + + declare readonly _serviceBrand: undefined; + + private readonly _handlers: IChatSubmitRequestHandler[] = []; + + register(handler: IChatSubmitRequestHandler): IDisposable { + this._handlers.push(handler); + return toDisposable(() => { + const index = this._handlers.indexOf(handler); + if (index >= 0) { + this._handlers.splice(index, 1); + } + }); + } + + async tryHandle(request: IChatSubmitRequest): Promise { + for (const handler of this._handlers) { + if (await handler.tryHandle(request)) { + return true; + } + } + return false; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 04d5e45c5b000a..fa85867a21b04d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -88,6 +88,7 @@ import { IChatDebugService } from '../../common/chatDebugService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { CHAT_READ_ONLY_BANNER_HEIGHT, ChatReadOnlyBanner } from './chatReadOnlyBanner.js'; +import { IChatSubmitRequestHandlerService } from '../chatSubmitRequestHandlerService.js'; const $ = dom.$; @@ -441,6 +442,7 @@ export class ChatWidget extends Disposable implements IChatWidget { @IChatDebugService private readonly chatDebugService: IChatDebugService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService, @IChatGoalSummaryService private readonly chatGoalSummaryService: IChatGoalSummaryService, + @IChatSubmitRequestHandlerService private readonly chatSubmitRequestHandlerService: IChatSubmitRequestHandlerService, ) { super(); @@ -2684,15 +2686,27 @@ export class ChatWidget extends Disposable implements IChatWidget { } } + const isUserQuery = !query; + const inputValue = isUserQuery ? this.getInput() : query.query; + if (isUserQuery) { + const preSubmitResult = await this.chatSubmitRequestHandlerService.tryHandle({ + sessionResource: this.viewModel.sessionResource, + input: inputValue, + }); + if (preSubmitResult) { + this.setInput(''); + return; + } + } + this._onDidAcceptInput.fire(); this.listWidget.setScrollLock(this.isLockedToCodingAgent || !!checkModeOption(this.input.currentModeKind, this.viewOptions.autoScroll)); - const editorValue = this.getInput(); const requestInputs: IChatRequestInputOptions = { - input: !query ? editorValue : query.query, + input: inputValue, attachedContext: options?.enableImplicitContext === false ? this.input.getAttachedContext() : this.input.getAttachedAndImplicitContext(), }; - const isUserQuery = !query; + const isEditing = this.viewModel?.editing; const editedModelRequestOptions = isEditing && this.configurationService.getValue('chat.editRequests') !== 'input' ? this.getSelectedModelRequestOptions() diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 6f5297f53850b8..ae124c68fe4cc4 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -1208,10 +1208,11 @@ export class LanguageModelsService implements ILanguageModelsService { } } + const wasResolved = this._modelsGroups.has(vendorId); const oldGroups = this._modelsGroups.get(vendorId) ?? []; this._modelsGroups.set(vendorId, languageModelsGroups); const oldModels = this._clearModelCache(vendorId); - let hasChanges = false; + let hasChanges = !wasResolved; for (const model of allModels) { if (this._modelCache.has(model.identifier)) { this._logService.warn(`[LM] Model ${model.identifier} is already registered. Skipping.`); diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index 1f9d7a39474ad8..84d8e1bdbdbaf8 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -37,6 +37,7 @@ import { AgentHostSessionListContribution } from '../browser/agentSessions/agent import { AgentHostOpenSessionLinkOpenerContribution } from '../browser/agentSessions/agentHost/openSessionLinkOpener.contribution.js'; import { AgentHostTerminalContribution } from '../browser/agentSessions/agentHost/agentHostTerminalContribution.js'; import { AgentHostCopilotCliSettingsContribution } from '../browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.js'; +import { CopilotConfigSlashSubmitHandlerContribution } from '../browser/agentSessions/agentHost/copilotConfigSlashSubmitHandler.js'; import '../browser/agentSessions/agentHost/agentHostSettings.contribution.js'; import '../browser/agentSessions/agentHost/agentSessionSettings.contribution.js'; import { AgentSessionProviders, getAgentSessionProviderName } from '../browser/agentSessions/agentSessions.js'; @@ -267,6 +268,7 @@ registerWorkbenchContribution2(ChatCommandLineHandler.ID, ChatCommandLineHandler registerWorkbenchContribution2(ChatSuspendThrottlingHandler.ID, ChatSuspendThrottlingHandler, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatLifecycleHandler.ID, ChatLifecycleHandler, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostContribution.ID, AgentHostContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(CopilotConfigSlashSubmitHandlerContribution.ID, CopilotConfigSlashSubmitHandlerContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostSessionListContribution.ID, AgentHostSessionListContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostOpenSessionLinkOpenerContribution.ID, AgentHostOpenSessionLinkOpenerContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(AgentHostTerminalContribution.ID, AgentHostTerminalContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/copilotConfigSlashSubmitHandler.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/copilotConfigSlashSubmitHandler.test.ts new file mode 100644 index 00000000000000..1d76dc725f7e56 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/copilotConfigSlashSubmitHandler.test.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { resolveCopilotConfigSlashSubmit } from '../../../browser/agentSessions/agentHost/copilotConfigSlashSubmitHandler.js'; + +suite('CopilotConfigSlashSubmitHandler', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('resolves typed config slash commands', () => { + assert.deepStrictEqual({ + yoloOn: resolveCopilotConfigSlashSubmit('/yolo on'), + yoloOff: resolveCopilotConfigSlashSubmit('/yolo off'), + yoloInvalid: resolveCopilotConfigSlashSubmit('/yolo onxxxcva'), + planPrompt: resolveCopilotConfigSlashSubmit('/plan implement this'), + unknown: resolveCopilotConfigSlashSubmit('/not-a-config-command'), + }, { + yoloOn: { applyConfig: { autoApprove: 'autoApprove' }, strippedPrompt: '' }, + yoloOff: { applyConfig: { autoApprove: 'default' }, strippedPrompt: '' }, + yoloInvalid: undefined, + planPrompt: { applyConfig: { mode: 'plan' }, strippedPrompt: 'implement this' }, + unknown: undefined, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatSubmitRequestHandlerService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatSubmitRequestHandlerService.test.ts new file mode 100644 index 00000000000000..79b2408260fdb7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatSubmitRequestHandlerService.test.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ChatSubmitRequestHandlerService } from '../../browser/chatSubmitRequestHandlerService.js'; + +suite('ChatSubmitRequestHandlerService', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns the first handler result', async () => { + const service = new ChatSubmitRequestHandlerService(); + const calls: string[] = []; + store.add(service.register({ + id: 'first', + async tryHandle() { + calls.push('first'); + return false; + }, + })); + store.add(service.register({ + id: 'second', + async tryHandle() { + calls.push('second'); + return true; + }, + })); + store.add(service.register({ + id: 'third', + async tryHandle() { + calls.push('third'); + return true; + }, + })); + + const result = await service.tryHandle({ + sessionResource: URI.parse('agent-host-copilotcli:/test'), + input: '/yolo on', + }); + + assert.deepStrictEqual({ result, calls }, { + result: true, + calls: ['first', 'second'], + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts index d390e0afe04eff..8e315df9cb9efa 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -620,6 +620,24 @@ suite('LanguageModels - Model Change Events', function () { assert.strictEqual(firedVendorId, 'test-vendor', 'Should fire event when new models are added'); }); + test('fires onChange when the first authoritative model resolution is empty', async function () { + const events: string[] = []; + disposables.add(languageModelsService.onDidChangeLanguageModels(vendorId => events.push(vendorId))); + disposables.add(languageModelsService.registerLanguageModelProvider('test-vendor', { + onDidChange: Event.None, + provideLanguageModelChatInfo: async () => [], + sendChatRequest: async () => { throw new Error(); }, + provideTokenCount: async () => { throw new Error(); }, + })); + + const models = await languageModelsService.selectLanguageModels({ vendor: 'test-vendor' }); + + assert.deepStrictEqual({ models, events }, { + models: [], + events: ['test-vendor'], + }); + }); + test('does not fire onChange event when models are unchanged', async function () { const models = [{ metadata: { @@ -2024,4 +2042,3 @@ suite('LanguageModels - provider usage telemetry', function () { assert.strictEqual(events.length, 0); }); }); - diff --git a/src/vs/workbench/contrib/terminal/test/browser/ahpTerminalCommandSource.test.ts b/src/vs/workbench/contrib/terminal/test/browser/ahpTerminalCommandSource.test.ts index f11c5d64b31bc8..538941830c9617 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/ahpTerminalCommandSource.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/ahpTerminalCommandSource.test.ts @@ -308,6 +308,24 @@ suite('AhpTerminalCommandSource', () => { assert.strictEqual(executing.getRawOutput(), 'hello world'); }); + test('streaming data arriving between executed and finished is kept as the command output', () => { + const source = createSource(); + + // The server dispatches data in stream order relative to command + // events, so a command's output arrives while the command is still + // executing and must survive as its output after it finishes. + simulateMark('cmd-1', AhpCommandMarkKind.Executed); + onCommandExecuted.fire({ commandId: 'cmd-1', commandLine: 'echo hi', timestamp: 1000 }); + + onWillData.fire('hi\r\n'); + + simulateMark('cmd-1', AhpCommandMarkKind.End); + onCommandFinished.fire({ commandId: 'cmd-1', exitCode: 0, durationMs: 5 }); + + const completed = source.commands[0] as AhpTerminalCommand; + assert.strictEqual(completed.getOutput(), 'hi\r\n'); + }); + test('streaming data is NOT appended to replayed commands', () => { const source = createSource(); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index b5596a0e310e02..ef85dff3d7fb0a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -53,6 +53,7 @@ import { IChatContextPickService } from '../../../../contrib/chat/browser/attach import { IChatContextService } from '../../../../contrib/chat/browser/contextContrib/chatContextService.js'; import { IChatImageCarouselService } from '../../../../contrib/chat/browser/chatImageCarouselService.js'; import { IChatInputNotificationService } from '../../../../contrib/chat/browser/widget/input/chatInputNotificationService.js'; +import { ChatSubmitRequestHandlerService, IChatSubmitRequestHandlerService } from '../../../../contrib/chat/browser/chatSubmitRequestHandlerService.js'; import { IChatMarkdownAnchorService } from '../../../../contrib/chat/browser/widget/chatContentParts/chatMarkdownAnchorService.js'; import { IChatWidgetHistoryService } from '../../../../contrib/chat/common/widget/chatWidgetHistoryService.js'; import { IChatModeService } from '../../../../contrib/chat/common/chatModes.js'; @@ -246,6 +247,7 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I override getActiveNotification() { return undefined; } override announceRendered() { } }()); + reg.defineInstance(IChatSubmitRequestHandlerService, new ChatSubmitRequestHandlerService()); reg.defineInstance(IAgentSessionsService, new class extends mock() { override readonly model = new class extends mock() { override readonly onDidChangeSessions = Event.None; }(); }()); // Agent-host chat widgets (e.g. the turn changes summary fixtures) create the // generic config chips lane, which opens a session subscription. Return an