From 4b5a5f83680aab972fb1b51cd30150be757a9d8f Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 20 Jul 2026 14:10:04 -0400 Subject: [PATCH 01/11] Add floating chat-input window with multi-session routing Introduces a frameless, always-on-top auxiliary window that hosts the real chat input (dictation, voice mode, and the input glow) via a compact, input-only ChatWidget. Submissions are scored against the current session list by a new ISessionRouter and dispatched with three outcomes: - low confidence -> start a brand-new session - several comparable high matches -> ask the user which session - a single clear match -> dispatch straight to it ISessionRouter keeps prompt/parse/heuristic logic pure so the scoring backend (renderer language model now; CAPI utility or a local model later) can be swapped without changing the contract; it degrades to a token-overlap heuristic when no model is available. Toggle via the command "Toggle Floating Chat Input Window". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aba188ca-29fb-4099-b0f9-e460fcb20613 --- .../chat/browser/chat.shared.contribution.ts | 3 + .../chatInputWindow.contribution.ts | 30 ++ .../chatInputWindow/chatInputWindowService.ts | 443 ++++++++++++++++++ .../sessionRouter/sessionRouterService.ts | 64 +++ .../contrib/chat/common/chatInputWindow.ts | 51 ++ .../contrib/chat/common/sessionRouter.ts | 182 +++++++ .../chat/test/common/sessionRouter.test.ts | 51 ++ src/vs/workbench/workbench.common.main.ts | 3 + 8 files changed, 827 insertions(+) create mode 100644 src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts create mode 100644 src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts create mode 100644 src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts create mode 100644 src/vs/workbench/contrib/chat/common/chatInputWindow.ts create mode 100644 src/vs/workbench/contrib/chat/common/sessionRouter.ts create mode 100644 src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts 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 30d4b403092b6d..e00692d327ff8b 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -62,6 +62,8 @@ import { ChatWidgetHistoryService, IChatWidgetHistoryService } from '../common/w import { BYOKUtilityModelDefault, ChatAgentLocation, ChatConfiguration, ChatDefaultPermissionLevel, ChatNotificationMode, ChatPermissionLevel } from '../common/constants.js'; import { ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService } from '../common/ignoredFiles.js'; import { ILanguageModelsService, LanguageModelsService } from '../common/languageModels.js'; +import { ISessionRouter } from '../common/sessionRouter.js'; +import { SessionRouterService } from './sessionRouter/sessionRouterService.js'; import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; import { ILanguageModelToolsConfirmationService } from '../common/tools/languageModelToolsConfirmationService.js'; import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; @@ -2824,6 +2826,7 @@ registerSingleton(IChatAccessibilityService, ChatAccessibilityService, Instantia registerSingleton(IChatWidgetHistoryService, ChatWidgetHistoryService, InstantiationType.Delayed); registerSingleton(ILanguageModelsConfigurationService, LanguageModelsConfigurationService, InstantiationType.Delayed); registerSingleton(ILanguageModelsService, LanguageModelsService, InstantiationType.Delayed); +registerSingleton(ISessionRouter, SessionRouterService, InstantiationType.Delayed); registerSingleton(ILanguageModelStatsService, LanguageModelStatsService, InstantiationType.Delayed); registerSingleton(IChatSlashCommandService, ChatSlashCommandService, InstantiationType.Delayed); registerSingleton(IChatAgentService, ChatAgentService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts new file mode 100644 index 00000000000000..d0bf899703a5b5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from '../../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { IChatInputWindowService } from '../../common/chatInputWindow.js'; + +// Registers the singleton implementation (side-effect import). +import './chatInputWindowService.js'; + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.toggleInputWindow', + title: nls.localize2('chat.toggleInputWindow', "Toggle Floating Chat Input Window"), + category: Categories.View, + f1: true, + precondition: ChatContextKeys.enabled, + }); + } + async run(accessor: ServicesAccessor): Promise { + const chatInputWindowService = accessor.get(IChatInputWindowService); + await chatInputWindowService.toggleWindow(); + } +}); diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts new file mode 100644 index 00000000000000..632259ee01cf2e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -0,0 +1,443 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; +import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IAuxiliaryWindowService, IAuxiliaryWindow } from '../../../../services/auxiliaryWindow/browser/auxiliaryWindowService.js'; +import { IRectangle } from '../../../../../platform/window/common/window.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { editorBackground } from '../../../../../platform/theme/common/colorRegistry.js'; +import { inputBackground, inputBorder } from '../../../../../platform/theme/common/colors/inputColors.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import { localize } from '../../../../../nls.js'; +import { ChatAgentLocation } from '../../common/constants.js'; +import { ChatMode } from '../../common/chatModes.js'; +import { ChatModeKind } from '../../common/constants.js'; +import { IChatModelReference, IChatService } from '../../common/chatService/chatService.js'; +import { ChatWidget } from '../widget/chatWidget.js'; +import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; +import { IAgentSession, AgentSessionStatus } from '../agentSessions/agentSessionsModel.js'; +import { ISessionRouter, IRoutableSession, ISessionRouteResult } from '../../common/sessionRouter.js'; +import { IChatInputWindowService, ChatInputWindowStorageKeys, CHAT_INPUT_WINDOW_DEFAULT_WIDTH, CHAT_INPUT_WINDOW_DEFAULT_HEIGHT } from '../../common/chatInputWindow.js'; + +/** + * Minimum confidence for a candidate to be treated as a real match. Below this + * for every candidate, the request starts a brand-new session instead. + */ +const ROUTE_CONFIDENCE_THRESHOLD = 0.5; + +/** + * When two or more matches are within this confidence margin of the top match, + * the result is treated as ambiguous and the user is asked to choose. + */ +const ROUTE_AMBIGUITY_MARGIN = 0.2; + +/** Maximum number of options shown in the disambiguation picker. */ +const ROUTE_MAX_CHOICES = 6; + +/** + * Hosts a frameless, always-on-top auxiliary window containing (eventually) the + * full chat input box — dictation, voice mode, and the glow animation. Step 1 + * opens an empty themed container so the window shell can be verified visually. + */ +export class ChatInputWindowService extends Disposable implements IChatInputWindowService { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeOpen = this._register(new Emitter()); + readonly onDidChangeOpen: Event = this._onDidChangeOpen.event; + + private readonly _auxiliaryWindowRef = this._register(new MutableDisposable()); + private _window: IAuxiliaryWindow | undefined; + private readonly _windowDisposables = this._register(new DisposableStore()); + private readonly _ownershipChannel: BroadcastChannel; + private _widget: ChatWidget | undefined; + private _modelRef: IChatModelReference | undefined; + /** Sessions loaded or spawned by routing; disposed when the window closes. */ + private readonly _routedSessionRefs: IChatModelReference[] = []; + + get isOpen(): boolean { + return !!this._window; + } + + constructor( + @IAuxiliaryWindowService private readonly auxiliaryWindowService: IAuxiliaryWindowService, + @IStorageService private readonly storageService: IStorageService, + @IThemeService private readonly themeService: IThemeService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IChatService private readonly chatService: IChatService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ISessionRouter private readonly sessionRouter: ISessionRouter, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + const ownershipChannel = new BroadcastChannel('chat-input-window-ownership'); + ownershipChannel.onmessage = (e) => { + if (e.data?.type === 'claim' && this._window) { + this.closeWindow(); + } + }; + this._register({ dispose: () => ownershipChannel.close() }); + this._ownershipChannel = ownershipChannel; + + const onBeforeUnload = () => { + if (this._window) { + this.closeWindow(); + } + }; + mainWindow.addEventListener('beforeunload', onBeforeUnload); + this._register({ dispose: () => mainWindow.removeEventListener('beforeunload', onBeforeUnload) }); + + const wasOpen = this.storageService.getBoolean(ChatInputWindowStorageKeys.WindowOpen, StorageScope.WORKSPACE, false); + if (wasOpen) { + this.storageService.store(ChatInputWindowStorageKeys.WindowOpen, false, StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + } + + async openWindow(): Promise { + if (this._window) { + return; + } + + const bounds = this._defaultBounds(); + + const auxiliaryWindow = await this.auxiliaryWindowService.open({ + bounds, + alwaysOnTop: true, + frameless: true, + transparent: false, + disableFullscreen: true, + nativeTitlebar: false, + noBackgroundThrottling: true, + backgroundColor: this.themeService.getColorTheme().getColor(editorBackground)?.toString() ?? '#1e1e1e', + }); + + this._window = auxiliaryWindow; + this._auxiliaryWindowRef.value = auxiliaryWindow; + + const workspace = this.workspaceContextService.getWorkspace(); + const projectName = workspace.folders.length > 0 ? workspace.folders[0].name : ''; + auxiliaryWindow.window.document.title = projectName ? `Chat Input — ${projectName}` : 'Chat Input'; + + auxiliaryWindow.container.style.overflow = 'hidden'; + auxiliaryWindow.window.document.body.style.setProperty('margin', '0', 'important'); + + // Resolve theme colors so the aux window matches the chat input box. + const theme = this.themeService.getColorTheme(); + const bgColor = theme.getColor(editorBackground)?.toString() ?? '#1e1e1e'; + const inputBg = theme.getColor(inputBackground)?.toString() ?? '#3C3C3C'; + const inputBd = theme.getColor(inputBorder)?.toString() ?? 'transparent'; + + auxiliaryWindow.container.style.setProperty('--vscode-chat-input-window-background', bgColor); + auxiliaryWindow.container.style.backgroundColor = inputBg; + auxiliaryWindow.container.style.border = `1px solid ${inputBd}`; + auxiliaryWindow.container.style.boxSizing = 'border-box'; + auxiliaryWindow.window.document.body.style.setProperty('background-color', inputBg, 'important'); + + this._windowDisposables.clear(); + + // Host the real chat input (dictation, voice mode, glow) by rendering a + // compact ChatWidget. The response list is filtered out so only the input + // box shows. Submission is intercepted via submitHandler (the routing + // seam) and currently dispatched to this window's dedicated session. + this._renderChatWidget(auxiliaryWindow); + + // Clean up when the user closes the window via OS controls. + Event.once(auxiliaryWindow.onUnload)(() => { + this._disposeWidget(); + this._window = undefined; + this._windowDisposables.clear(); + this._auxiliaryWindowRef.value = undefined; + this.storageService.store(ChatInputWindowStorageKeys.WindowOpen, false, StorageScope.WORKSPACE, StorageTarget.MACHINE); + this._onDidChangeOpen.fire(false); + }); + + this.storageService.store(ChatInputWindowStorageKeys.WindowOpen, true, StorageScope.WORKSPACE, StorageTarget.MACHINE); + this._onDidChangeOpen.fire(true); + } + + closeWindow(): void { + if (!this._window) { return; } + + this.storageService.store(ChatInputWindowStorageKeys.WindowOpen, false, StorageScope.WORKSPACE, StorageTarget.MACHINE); + + this._disposeWidget(); + this._window = undefined; + this._windowDisposables.clear(); + this._auxiliaryWindowRef.value = undefined; + this._onDidChangeOpen.fire(false); + } + + async toggleWindow(): Promise { + if (this.isOpen) { + this.closeWindow(); + } else { + this._ownershipChannel.postMessage({ type: 'claim' }); + await this.openWindow(); + } + } + + private _renderChatWidget(auxiliaryWindow: IAuxiliaryWindow): void { + // The glow CSS keys off `.monaco-workbench .interactive-session + // .chat-input-container` — the aux container already tracks the + // `monaco-workbench` class, so we only need the `.interactive-session` + // wrapper here. + const parent = dom.append(auxiliaryWindow.container, dom.$('.interactive-session')); + parent.style.height = '100%'; + parent.style.width = '100%'; + + const scopedInstantiationService = this._windowDisposables.add(this.instantiationService.createChild( + new ServiceCollection([ + IContextKeyService, + this._windowDisposables.add(this.contextKeyService.createScoped(parent)), + ]) + )); + + const widget = this._windowDisposables.add(scopedInstantiationService.createInstance( + ChatWidget, + ChatAgentLocation.Chat, + { isQuickChat: true }, + { + autoScroll: true, + renderInputOnTop: true, + renderStyle: 'compact', + // Show only the input box — drop every response list item. + filter: () => false, + enableImplicitContext: false, + defaultMode: ChatMode.Ask, + menus: { inputSideToolbar: MenuId.ChatInputSide, telemetrySource: 'chatInputWindow' }, + // Routing seam: intercept submission before local execution. For + // now dispatch to this window's dedicated session; ISessionRouter + // will replace this to fan out to the best-matching session. + submitHandler: (query, mode) => this._handleSubmit(query, mode), + }, + { + inputEditorBackground: inputBackground, + resultEditorBackground: editorBackground, + listBackground: editorBackground, + listForeground: editorBackground, + overlayBackground: editorBackground, + } + )); + widget.render(parent); + widget.setVisible(true); + + const modelRef = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { disableBackgroundKeepAlive: true, debugOwner: 'ChatInputWindow' }); + this._modelRef = modelRef; + widget.setModel(modelRef.object); + this._widget = widget; + + const layout = () => widget.layout(parent.offsetHeight, parent.offsetWidth); + layout(); + this._windowDisposables.add(dom.addDisposableListener(auxiliaryWindow.window, 'resize', layout)); + this._windowDisposables.add(widget.onDidChangeHeight(() => layout())); + } + + /** + * Routing seam with three outcomes: + * 1. No confident match → start a brand-new session for the request. + * 2. Several comparable high matches → ask the user which session to use. + * 3. A single clear high match → dispatch straight to it. + * Always returns `true` (handled) so the input-only widget never runs the + * request on its own scratch session. + */ + private async _handleSubmit(query: string, _mode: ChatModeKind): Promise { + const utterance = query.trim(); + if (!utterance) { + return false; + } + + const candidates = this._collectCandidateSessions(); + if (!candidates.length) { + // Nothing to route to — this is the first request; make a new session. + return this._dispatchToNewSession(utterance); + } + + const cts = new CancellationTokenSource(); + let results: ISessionRouteResult[]; + try { + results = await this.sessionRouter.route({ utterance, sessions: candidates }, cts.token); + } catch (err) { + this.logService.warn('[chatInputWindow] session routing failed:', err); + return this._dispatchToNewSession(utterance); + } finally { + cts.dispose(); + } + + const top = results[0]; + + // State 1: low confidence across the board → new session. + if (!top || top.confidence < ROUTE_CONFIDENCE_THRESHOLD) { + return this._dispatchToNewSession(utterance); + } + + // Candidates that are both above threshold and close to the top match. + const closeMatches = results.filter(r => + r.confidence >= ROUTE_CONFIDENCE_THRESHOLD && (top.confidence - r.confidence) <= ROUTE_AMBIGUITY_MARGIN); + + // State 2: ambiguous — several comparable matches → ask the user. + if (closeMatches.length >= 2) { + const labelById = new Map(candidates.map(c => [c.sessionId, c.label])); + const choice = await this._promptSessionChoice(closeMatches, labelById); + if (choice === undefined) { + // User dismissed the picker — leave the input untouched to retry. + return true; + } + if (choice === 'new') { + return this._dispatchToNewSession(utterance); + } + return this._dispatchToSession(choice, utterance); + } + + // State 3: a single clear winner → dispatch directly. + return this._dispatchToSession(top.sessionId, utterance); + } + + /** + * Snapshot the current agent sessions as routing candidates, excluding this + * window's own scratch session so it can never route to itself. + */ + private _collectCandidateSessions(): IRoutableSession[] { + const ownResource = this._modelRef?.object.sessionResource.toString(); + this.agentSessionsService.model.resolve(undefined); + return this.agentSessionsService.model.sessions + .filter(session => session.resource.toString() !== ownResource) + .map(session => this._toRoutableSession(session)); + } + + private _toRoutableSession(session: IAgentSession): IRoutableSession { + return { + sessionId: session.resource.toString(), + label: session.label, + status: statusToString(session.status), + lastActivity: session.timing?.lastRequestEnded ?? session.timing?.lastRequestStarted ?? session.timing?.created, + }; + } + + /** + * Ask the user to pick a target when several sessions match with comparable + * confidence. Returns the chosen session id, `'new'` for a new session, or + * `undefined` if the picker was dismissed. + */ + private async _promptSessionChoice(matches: ISessionRouteResult[], labelById: Map): Promise { + type RouteChoiceItem = IQuickPickItem & { sessionId?: string; isNew?: boolean }; + const items: RouteChoiceItem[] = matches.slice(0, ROUTE_MAX_CHOICES).map(match => ({ + label: labelById.get(match.sessionId) ?? match.sessionId, + description: localize('chatInputWindow.matchPercent', "{0}% match", Math.round(match.confidence * 100)), + detail: match.reason, + sessionId: match.sessionId, + })); + items.push({ + label: localize('chatInputWindow.newSession', "$(add) Start a new session"), + isNew: true, + }); + + const picked = await this.quickInputService.pick(items, { + placeHolder: localize('chatInputWindow.choosePlaceholder', "Multiple sessions match — choose where to send this request"), + }); + if (!picked) { + return undefined; + } + return picked.isNew ? 'new' : picked.sessionId; + } + + private async _dispatchToSession(sessionId: string, utterance: string): Promise { + let target: URI; + try { + target = URI.parse(sessionId); + } catch (err) { + this.logService.warn('[chatInputWindow] invalid session id for routing:', sessionId, err); + return this._dispatchToNewSession(utterance); + } + + const cts = new CancellationTokenSource(); + try { + const ref = await this.chatService.acquireOrLoadSession(target, ChatAgentLocation.Chat, cts.token, 'chatInputWindow-route'); + if (!ref) { + this.logService.warn('[chatInputWindow] could not load routed session, starting a new one:', sessionId); + return this._dispatchToNewSession(utterance); + } + this._routedSessionRefs.push(ref); + const result = await this.chatService.sendRequest(target, utterance); + if (!result || result.kind === 'rejected') { + this.logService.warn('[chatInputWindow] routed session rejected the request, starting a new one:', sessionId); + return this._dispatchToNewSession(utterance); + } + this._widget?.inputEditor.setValue(''); + return true; + } catch (err) { + this.logService.warn('[chatInputWindow] error dispatching to routed session, starting a new one:', err); + return this._dispatchToNewSession(utterance); + } finally { + cts.dispose(); + } + } + + private async _dispatchToNewSession(utterance: string): Promise { + try { + const ref = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { debugOwner: 'chatInputWindow-new' }); + this._routedSessionRefs.push(ref); + const result = await this.chatService.sendRequest(ref.object.sessionResource, utterance); + if (!result || result.kind === 'rejected') { + this.logService.warn('[chatInputWindow] new session rejected the request, running locally'); + return false; + } + this._widget?.inputEditor.setValue(''); + return true; + } catch (err) { + this.logService.warn('[chatInputWindow] error starting a new session, running locally:', err); + return false; + } + } + + private _disposeWidget(): void { + this._widget = undefined; + this._modelRef?.dispose(); + this._modelRef = undefined; + for (const ref of this._routedSessionRefs) { + ref.dispose(); + } + this._routedSessionRefs.length = 0; + } + + private _defaultBounds(): IRectangle { + // Center horizontally within the main VS Code window, near the bottom. + const x = Math.round(mainWindow.screenX + (mainWindow.outerWidth - CHAT_INPUT_WINDOW_DEFAULT_WIDTH) / 2); + const y = mainWindow.screenY + mainWindow.outerHeight - CHAT_INPUT_WINDOW_DEFAULT_HEIGHT - 100; + return { + x, + y, + width: CHAT_INPUT_WINDOW_DEFAULT_WIDTH, + height: CHAT_INPUT_WINDOW_DEFAULT_HEIGHT, + }; + } +} + +registerSingleton(IChatInputWindowService, ChatInputWindowService, InstantiationType.Delayed); + +function statusToString(status: AgentSessionStatus): string { + switch (status) { + case AgentSessionStatus.Failed: return 'failed'; + case AgentSessionStatus.Completed: return 'idle'; + case AgentSessionStatus.InProgress: return 'working'; + default: return 'unknown'; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts new file mode 100644 index 00000000000000..b62256f8feb03e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { ChatMessageRole, getTextResponseFromStream, IChatMessage, ILanguageModelsService } from '../../common/languageModels.js'; +import { buildRouterMessages, heuristicScore, ISessionRouteRequest, ISessionRouteResult, ISessionRouter, parseRouterResponse } from '../../common/sessionRouter.js'; + +/** + * Default {@link ISessionRouter}. Scores candidate sessions with a renderer + * language model (Copilot/CAPI under the hood) and degrades to a local + * heuristic when no model is available or the response can't be parsed. + * + * The prompt/parse logic lives in `../../common/sessionRouter.ts` so the scoring + * backend can later be swapped for the agent-host CAPI utility completion or a + * local model without changing this service's contract. + */ +export class SessionRouterService implements ISessionRouter { + + declare readonly _serviceBrand: undefined; + + constructor( + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @ILogService private readonly logService: ILogService, + ) { } + + async route(request: ISessionRouteRequest, token: CancellationToken): Promise { + if (!request.sessions.length) { + return []; + } + const scored = await this.scoreWithModel(request, token); + return scored ?? heuristicScore(request); + } + + private async scoreWithModel(request: ISessionRouteRequest, token: CancellationToken): Promise { + let modelId: string | undefined; + try { + const models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot' }); + modelId = models.at(0); + } catch (err) { + this.logService.trace('[SessionRouter] model selection failed, falling back to heuristic', err); + } + if (!modelId) { + return undefined; + } + + const messages: IChatMessage[] = buildRouterMessages(request).map(message => ({ + role: message.role === 'system' ? ChatMessageRole.System : ChatMessageRole.User, + content: [{ type: 'text', value: message.content }] + })); + + try { + const response = await this.languageModelsService.sendChatRequest(modelId, undefined, messages, {}, token); + const text = await getTextResponseFromStream(response); + const validIds = new Set(request.sessions.map(session => session.sessionId)); + return parseRouterResponse(text, validIds); + } catch (err) { + this.logService.trace('[SessionRouter] scoring request failed, falling back to heuristic', err); + return undefined; + } + } +} diff --git a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts new file mode 100644 index 00000000000000..bf41d16ff46a17 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { Event } from '../../../../base/common/event.js'; + +/** + * Default dimensions for the floating chat input window. + */ +export const CHAT_INPUT_WINDOW_DEFAULT_WIDTH = 520; +export const CHAT_INPUT_WINDOW_DEFAULT_HEIGHT = 110; + +/** + * Storage keys for persisting window state across restarts. + */ +export const enum ChatInputWindowStorageKeys { + WindowOpen = 'chatInputWindow.windowOpen', +} + +export const IChatInputWindowService = createDecorator('chatInputWindowService'); + +export interface IChatInputWindowService { + readonly _serviceBrand: undefined; + + /** + * Whether the floating chat input window is currently open. + */ + readonly isOpen: boolean; + + /** + * Fires when the window opens or closes. + */ + readonly onDidChangeOpen: Event; + + /** + * Opens the floating chat input window. No-op if already open. + */ + openWindow(): Promise; + + /** + * Closes the floating chat input window. No-op if already closed. + */ + closeWindow(): void; + + /** + * Toggles the floating chat input window open/closed. + */ + toggleWindow(): Promise; +} diff --git a/src/vs/workbench/contrib/chat/common/sessionRouter.ts b/src/vs/workbench/contrib/chat/common/sessionRouter.ts new file mode 100644 index 00000000000000..54ec8b1401e5da --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/sessionRouter.ts @@ -0,0 +1,182 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +/** + * A session that a user request can be routed to. Populated by the caller from + * the session list (e.g. `IChatSessionsService` / `ISessionsService`). + */ +export interface IRoutableSession { + /** Stable identifier used to dispatch the request (e.g. via a `send_message` tool). */ + readonly sessionId: string; + /** Human-readable session name shown to the user. */ + readonly label: string; + /** Owning repository, when known (e.g. `owner/repo`). */ + readonly repo?: string; + /** Working directory of the session, when known. */ + readonly cwd?: string; + /** Coarse activity state (e.g. `idle`, `working`), when known. */ + readonly status?: string; + /** Epoch milliseconds of the last activity, when known. */ + readonly lastActivity?: number; +} + +/** A single scored candidate produced by the router, sorted best-first. */ +export interface ISessionRouteResult { + readonly sessionId: string; + /** Match confidence in the range [0, 1]. */ + readonly confidence: number; + /** Optional short rationale for display/debugging. */ + readonly reason?: string; +} + +export interface ISessionRouteRequest { + /** The raw user utterance (e.g. dictated text) to route. */ + readonly utterance: string; + /** Candidate sessions to score against. */ + readonly sessions: readonly IRoutableSession[]; +} + +export const ISessionRouter = createDecorator('sessionRouter'); + +/** + * Scores which existing session a free-form user request best matches, so a + * floating input / voice surface can route the request (or disambiguate when no + * candidate is confident enough). + */ +export interface ISessionRouter { + readonly _serviceBrand: undefined; + + /** + * Rank the candidate sessions for the given utterance, best match first. + * Never rejects for routing reasons: on model/parse failure it degrades to a + * local heuristic so callers always receive a usable ranking. + */ + route(request: ISessionRouteRequest, token: CancellationToken): Promise; +} + +// ─── Prompt + parsing helpers (pure; reused by any scoring backend) ────────── + +/** A provider-agnostic chat message used to prompt the scoring model. */ +export interface ISessionRouterMessage { + readonly role: 'system' | 'user'; + readonly content: string; +} + +/** + * Build the chat messages sent to the scoring model. Kept pure and exported so + * the same prompt can back a renderer language-model request, a CAPI utility + * completion, or a local model without divergence. + */ +export function buildRouterMessages(request: ISessionRouteRequest): ISessionRouterMessage[] { + const sessionLines = request.sessions.map(session => { + const parts = [`id=${session.sessionId}`, `name=${JSON.stringify(session.label)}`]; + if (session.repo) { parts.push(`repo=${session.repo}`); } + if (session.cwd) { parts.push(`cwd=${session.cwd}`); } + if (session.status) { parts.push(`status=${session.status}`); } + return `- ${parts.join(' ')}`; + }).join('\n'); + + const system = [ + 'You route a user request to the coding session it most likely refers to.', + 'Score every candidate session from 0 (no match) to 1 (certain match).', + 'Respond with ONLY a JSON array, sorted by confidence descending, of objects:', + '[{"sessionId": string, "confidence": number, "reason": string}]', + 'Do not include any prose or code fences.' + ].join('\n'); + + const user = `Request: ${JSON.stringify(request.utterance)}\nSessions:\n${sessionLines}`; + + return [ + { role: 'system', content: system }, + { role: 'user', content: user } + ]; +} + +/** + * Parse the scoring model's raw text response into results, keeping only known + * session ids and clamping confidences to [0, 1]. Tolerates code fences and + * surrounding prose by extracting the first JSON array. Returns `undefined` when + * nothing usable can be parsed, signalling callers to fall back. + */ +export function parseRouterResponse(text: string, validSessionIds: ReadonlySet): ISessionRouteResult[] | undefined { + const match = text.match(/\[[\s\S]*\]/); + if (!match) { + return undefined; + } + + let parsed: unknown; + try { + parsed = JSON.parse(match[0]); + } catch { + return undefined; + } + if (!Array.isArray(parsed)) { + return undefined; + } + + const results: ISessionRouteResult[] = []; + const seen = new Set(); + for (const entry of parsed) { + if (!entry || typeof entry !== 'object') { + continue; + } + const record = entry as Record; + const sessionId = record.sessionId; + if (typeof sessionId !== 'string' || !validSessionIds.has(sessionId) || seen.has(sessionId)) { + continue; + } + const rawConfidence = record.confidence; + const confidence = typeof rawConfidence === 'number' && isFinite(rawConfidence) + ? Math.max(0, Math.min(1, rawConfidence)) + : 0; + seen.add(sessionId); + results.push({ + sessionId, + confidence, + reason: typeof record.reason === 'string' ? record.reason : undefined + }); + } + + if (!results.length) { + return undefined; + } + results.sort((a, b) => b.confidence - a.confidence); + return results; +} + +/** + * Zero-dependency offline ranking used as the fallback when no scoring model is + * available. Token-overlap heuristic over the session label/repo/cwd; good + * enough to keep the routing UI functional without a model. + */ +export function heuristicScore(request: ISessionRouteRequest): ISessionRouteResult[] { + const terms = tokenize(request.utterance); + const results = request.sessions.map(session => { + const haystack = new Set(tokenize([session.label, session.repo, session.cwd].filter(isNonEmpty).join(' '))); + if (!terms.length || !haystack.size) { + return { sessionId: session.sessionId, confidence: 0 }; + } + let hits = 0; + for (const term of terms) { + if (haystack.has(term)) { + hits++; + } + } + return { sessionId: session.sessionId, confidence: hits / terms.length }; + }); + results.sort((a, b) => b.confidence - a.confidence); + return results; +} + +function tokenize(text: string): string[] { + return text.toLowerCase().split(/[^a-z0-9]+/).filter(term => term.length > 1); +} + +function isNonEmpty(value: string | undefined): value is string { + return !!value; +} diff --git a/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts b/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts new file mode 100644 index 00000000000000..28c64ff128feb0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { buildRouterMessages, heuristicScore, ISessionRouteRequest, parseRouterResponse } from '../../common/sessionRouter.js'; + +suite('SessionRouter helpers', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const request: ISessionRouteRequest = { + utterance: 'fix the flaky voice reconnect test', + sessions: [ + { sessionId: 's1', label: 'voice narration', repo: 'meganrogge/momentum-map', status: 'idle' }, + { sessionId: 's2', label: 'docs cleanup', repo: 'microsoft/vscode-docs' } + ] + }; + + test('buildRouterMessages embeds utterance and every session id', () => { + const messages = buildRouterMessages(request); + assert.strictEqual(messages.length, 2); + assert.strictEqual(messages[0].role, 'system'); + assert.strictEqual(messages[1].role, 'user'); + assert.ok(messages[1].content.includes('fix the flaky voice reconnect test')); + assert.ok(messages[1].content.includes('id=s1')); + assert.ok(messages[1].content.includes('id=s2')); + }); + + test('parseRouterResponse extracts, clamps, filters and sorts', () => { + const raw = '```json\n[{"sessionId":"s2","confidence":0.2},{"sessionId":"s1","confidence":1.7,"reason":"voice"},{"sessionId":"ghost","confidence":0.9}]\n```'; + const result = parseRouterResponse(raw, new Set(['s1', 's2'])); + assert.deepStrictEqual(result, [ + { sessionId: 's1', confidence: 1, reason: 'voice' }, + { sessionId: 's2', confidence: 0.2, reason: undefined } + ]); + }); + + test('parseRouterResponse returns undefined when nothing usable', () => { + assert.strictEqual(parseRouterResponse('no json here', new Set(['s1'])), undefined); + assert.strictEqual(parseRouterResponse('[{"sessionId":"unknown","confidence":0.5}]', new Set(['s1'])), undefined); + }); + + test('heuristicScore ranks the token-overlapping session first', () => { + const ranked = heuristicScore(request); + assert.strictEqual(ranked[0].sessionId, 's1'); + assert.ok(ranked[0].confidence > ranked[1].confidence); + }); +}); diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 2683132becc398..20e8a2804278f0 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -230,6 +230,9 @@ import './contrib/inlineChat/browser/inlineChat.contribution.js'; // Copilot Voice import './contrib/agentsVoice/browser/agentsVoice.contribution.js'; + +// Floating Chat Input Window +import './contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.js'; import './contrib/mcp/browser/mcp.contribution.js'; import './contrib/mcp/browser/mcp.view.contribution.js'; import './contrib/chat/browser/chatSessions/chatSessions.contribution.js'; From 59f8a3aafefec56083c436a8882131186170c139 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 20 Jul 2026 14:32:20 -0400 Subject: [PATCH 02/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../chat/browser/chatInputWindow/chatInputWindowService.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index 632259ee01cf2e..0ee2e4ebe9edbe 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -103,8 +103,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind this.closeWindow(); } }; - mainWindow.addEventListener('beforeunload', onBeforeUnload); - this._register({ dispose: () => mainWindow.removeEventListener('beforeunload', onBeforeUnload) }); + this._register(dom.addDisposableListener(mainWindow, 'beforeunload', onBeforeUnload)); const wasOpen = this.storageService.getBoolean(ChatInputWindowStorageKeys.WindowOpen, StorageScope.WORKSPACE, false); if (wasOpen) { From 0e6fca813c3c33e29e84dc40ef292e3e6dbd69b3 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 20 Jul 2026 14:32:42 -0400 Subject: [PATCH 03/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../chat/browser/chatInputWindow/chatInputWindowService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index 0ee2e4ebe9edbe..b9db2d99f1cd68 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -134,7 +134,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind const workspace = this.workspaceContextService.getWorkspace(); const projectName = workspace.folders.length > 0 ? workspace.folders[0].name : ''; - auxiliaryWindow.window.document.title = projectName ? `Chat Input — ${projectName}` : 'Chat Input'; + auxiliaryWindow.window.document.title = projectName ? localize('chatInputWindow.titleWithProject', "Chat Input — {0}", projectName) : localize('chatInputWindow.title', "Chat Input"); auxiliaryWindow.container.style.overflow = 'hidden'; auxiliaryWindow.window.document.body.style.setProperty('margin', '0', 'important'); From 6dfd7c204404ac1bb65f8d9ff82aed6f683aeff0 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 20 Jul 2026 15:06:49 -0400 Subject: [PATCH 04/11] Address review feedback on floating chat-input window - Fix hygiene failure: replace non-ASCII box-drawing separator comment in sessionRouter.ts with ASCII; move $(add) icon outside the localized string. - Draggable frameless window: add a -webkit-app-region drag handle strip. - Localize the window document title with a placeholder. - Reapply themed colors on onDidColorThemeChange. - Dedicated accessibility help (routing + how to close) via inChatInputWindow context key, outranking the generic Quick Chat help. - Forward and clear explicit attachments through the routing submit contract. - Dedicated scoped ChatInputWindowSide menu + Close action (no longer closes the unrelated Quick Chat surface). - Only clear the input if the editor still holds the submitted text. - Window-scoped submission CancellationTokenSource, canceled on close. - Await agent-sessions model.resolve before snapshotting candidates. - Retain at most one session reference per resource (ResourceMap dedupe). - Calibrate the offline heuristic against candidate metadata + threshold test. - Coalesce concurrent openWindow calls; guard unload cleanup by window identity. - Use dom.addDisposableListener for the beforeunload listener. - Rethrow CancellationError from the router instead of degrading to heuristic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8904e5b5-6e65-4b9c-8ec5-ac060df81978 --- .../accessibility/browser/accessibleView.ts | 1 + src/vs/platform/actions/common/actions.ts | 1 + .../browser/actions/chatAccessibilityHelp.ts | 27 +- .../chat/browser/chat.shared.contribution.ts | 3 +- src/vs/workbench/contrib/chat/browser/chat.ts | 7 +- .../chatInputWindow.contribution.ts | 23 +- .../chatInputWindow/chatInputWindowService.ts | 245 +++++++++++++----- .../sessionRouter/sessionRouterService.ts | 6 + .../contrib/chat/browser/widget/chatWidget.ts | 3 +- .../chat/common/actions/chatContextKeys.ts | 1 + .../contrib/chat/common/sessionRouter.ts | 44 +++- .../chat/test/common/sessionRouter.test.ts | 13 + 12 files changed, 291 insertions(+), 83 deletions(-) diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index 9461288c8811d1..91cb30aaf936de 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -25,6 +25,7 @@ export const enum AccessibleViewProviderId { InlineChat = 'inlineChat', AgentChat = 'agentChat', QuickChat = 'quickChat', + ChatInputWindow = 'chatInputWindow', InlineCompletions = 'inlineCompletions', KeybindingsEditor = 'keybindingsEditor', Notebook = 'notebook', diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index c775a60c48accb..00fd8712044dcf 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -265,6 +265,7 @@ export class MenuId { static readonly ChatInputSecondary = new MenuId('ChatInputSecondary'); static readonly ChatInputStatus = new MenuId('ChatInputStatus'); static readonly ChatInputSide = new MenuId('ChatInputSide'); + static readonly ChatInputWindowSide = new MenuId('ChatInputWindowSide'); static readonly AutomationsDialogInput = new MenuId('AutomationsDialogInput'); static readonly ChatModePicker = new MenuId('ChatModePicker'); static readonly ChatEditingWidgetToolbar = new MenuId('ChatEditingWidgetToolbar'); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 4ee7dfe1a5f4eb..4cfae7583e1824 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -40,6 +40,16 @@ export class QuickChatAccessibilityHelp implements IAccessibleViewImplementation } } +export class ChatInputWindowAccessibilityHelp implements IAccessibleViewImplementation { + readonly priority = 121; + readonly name = 'chatInputWindow'; + readonly type = AccessibleViewType.Help; + readonly when = ChatContextKeys.inChatInputWindow; + getProvider(accessor: ServicesAccessor) { + return getChatAccessibilityHelpProvider(accessor, undefined, 'chatInputWindow'); + } +} + export class EditsChatAccessibilityHelp implements IAccessibleViewImplementation { readonly priority = 119; readonly name = 'editsView'; @@ -60,8 +70,17 @@ export class AgentChatAccessibilityHelp implements IAccessibleViewImplementation } } -export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', keybindingService: IKeybindingService): string { +export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView' | 'chatInputWindow', keybindingService: IKeybindingService): string { const content = []; + if (type === 'chatInputWindow') { + content.push(localize('chatInputWindow.overview', 'The floating chat input window is an input-only surface. It has no response list; instead each request you submit is routed to the coding session it best matches, and its response appears in that session rather than here.')); + content.push(localize('chatInputWindow.routing', 'When no existing session is a confident match, a new session is started for the request. When several sessions match with comparable confidence, you are asked to choose one from a picker, which also offers starting a new session.')); + content.push(localize('chatInputWindow.requestHistory', 'In the input box, use up and down arrows to navigate your request history. Edit input and use Enter or the submit button to route a new request.')); + content.push(localize('chatInputWindow.dictate', 'To dictate your request using on-device speech-to-text, invoke the Dictate command{0}. Invoke it again to stop.', '')); + content.push(localize('chatInputWindow.close', 'To close the floating chat input window, invoke the Close Floating Chat Input Window command, or toggle it with the Toggle Floating Chat Input Window command{0}.', '')); + content.push(localize('chatInputWindow.signals', "Accessibility Signals can be changed via settings with a prefix of signals.chat. By default, if a request takes more than 4 seconds, you will hear a sound indicating that progress is still occurring.")); + return content.join('\n'); + } if (type === 'panelChat' || type === 'quickChat' || type === 'editsView' || type === 'agentView') { content.push(localize('chat.fileChangesDisclosure', 'File change summaries show the total files, additions, and deletions. Focus the disclosure and press Enter or Space to show or hide the individual files.')); } @@ -148,7 +167,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui return content.join('\n'); } -export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, editor: ICodeEditor | undefined, type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView'): AccessibleContentProvider | undefined { +export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, editor: ICodeEditor | undefined, type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView' | 'chatInputWindow'): AccessibleContentProvider | undefined { const widgetService = accessor.get(IChatWidgetService); const keybindingService = accessor.get(IKeybindingService); const inputEditor: ICodeEditor | undefined = widgetService.lastFocusedWidget?.inputEditor; @@ -165,11 +184,11 @@ export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, edi inputEditor.getSupportedActions(); const helpText = getAccessibilityHelpText(type, keybindingService); return new AccessibleContentProvider( - type === 'panelChat' ? AccessibleViewProviderId.PanelChat : type === 'inlineChat' ? AccessibleViewProviderId.InlineChat : type === 'agentView' ? AccessibleViewProviderId.AgentChat : AccessibleViewProviderId.QuickChat, + type === 'panelChat' ? AccessibleViewProviderId.PanelChat : type === 'inlineChat' ? AccessibleViewProviderId.InlineChat : type === 'agentView' ? AccessibleViewProviderId.AgentChat : type === 'chatInputWindow' ? AccessibleViewProviderId.ChatInputWindow : AccessibleViewProviderId.QuickChat, { type: AccessibleViewType.Help }, () => helpText, () => { - if (type === 'quickChat' || type === 'editsView' || type === 'agentView' || type === 'panelChat') { + if (type === 'quickChat' || type === 'editsView' || type === 'agentView' || type === 'panelChat' || type === 'chatInputWindow') { if (cachedPosition) { inputEditor.setPosition(cachedPosition); } 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 e00692d327ff8b..1d79500434bb50 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -94,7 +94,7 @@ import './voiceClient/ttsPlaybackService.js'; import './voiceClient/voiceToolDispatchService.js'; import './voiceClient/voiceSessionController.js'; import { registerChatAccessibilityActions } from './actions/chatAccessibilityActions.js'; -import { AgentChatAccessibilityHelp, EditsChatAccessibilityHelp, PanelChatAccessibilityHelp, QuickChatAccessibilityHelp } from './actions/chatAccessibilityHelp.js'; +import { AgentChatAccessibilityHelp, ChatInputWindowAccessibilityHelp, EditsChatAccessibilityHelp, PanelChatAccessibilityHelp, QuickChatAccessibilityHelp } from './actions/chatAccessibilityHelp.js'; import { ModeOpenChatGlobalAction, registerChatActions } from './actions/chatActions.js'; import { CodeBlockActionRendering, registerChatCodeBlockActions, registerChatCodeCompareBlockActions } from './actions/chatCodeblockActions.js'; import { ChatContextContributions } from './actions/chatContext.js'; @@ -2726,6 +2726,7 @@ AccessibleViewRegistry.register(new PanelChatAccessibilityHelp()); AccessibleViewRegistry.register(new QuickChatAccessibilityHelp()); AccessibleViewRegistry.register(new EditsChatAccessibilityHelp()); AccessibleViewRegistry.register(new AgentChatAccessibilityHelp()); +AccessibleViewRegistry.register(new ChatInputWindowAccessibilityHelp()); registerEditorFeature(ChatInputBoxContentProvider); Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatEditorInput.TypeID, ChatEditorInputSerializer); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index d3794452a7da69..fb1247dc84d9d5 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -24,6 +24,7 @@ import { ChatRequestQueueKind, IChatElicitationRequest, IChatLocationData, IChat import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatPendingDividerViewModel } from '../common/model/chatViewModel.js'; import { ChatAgentLocation, ChatModeKind } from '../common/constants.js'; import { ChatAttachmentModel } from './attachments/chatAttachmentModel.js'; +import { IChatRequestVariableEntry } from '../common/attachments/chatVariableEntries.js'; import { IChatEditorOptions } from './widgetHosts/editor/chatEditor.js'; import { ChatInputPart } from './widget/input/chatInputPart.js'; import { ChatWidget, IChatWidgetContrib } from './widget/chatWidget.js'; @@ -305,8 +306,12 @@ export interface IChatWidgetViewOptions { * If it returns true (handled), the normal submission is skipped. * This is useful for contexts like the welcome view where submission should * redirect to a different workspace rather than executing locally. + * + * `attachedContext` carries the explicit attachments (paste/drop/pick) present + * on the input so a host that routes the request elsewhere can forward them + * instead of silently dropping them. */ - submitHandler?: (query: string, mode: ChatModeKind) => Promise; + submitHandler?: (query: string, mode: ChatModeKind, attachedContext?: IChatRequestVariableEntry[]) => Promise; /** * Whether we are running in the sessions window. diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts index d0bf899703a5b5..836ed3196b98f8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as nls from '../../../../../nls.js'; -import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; @@ -28,3 +29,23 @@ registerAction2(class extends Action2 { await chatInputWindowService.toggleWindow(); } }); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.closeInputWindow', + title: nls.localize2('chat.closeInputWindow', "Close Floating Chat Input Window"), + category: Categories.View, + f1: false, + icon: Codicon.close, + menu: { + id: MenuId.ChatInputWindowSide, + group: 'navigation', + order: 10 + } + }); + } + run(accessor: ServicesAccessor): void { + accessor.get(IChatInputWindowService).closeWindow(); + } +}); diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index b9db2d99f1cd68..98202d0fe61354 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -6,7 +6,8 @@ import * as dom from '../../../../../base/browser/dom.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; -import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { CancellationTokenSource, CancellationToken } from '../../../../../base/common/cancellation.js'; +import { ResourceMap } from '../../../../../base/common/map.js'; import { URI } from '../../../../../base/common/uri.js'; import { mainWindow } from '../../../../../base/browser/window.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; @@ -24,13 +25,14 @@ import { inputBackground, inputBorder } from '../../../../../platform/theme/comm import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; import { localize } from '../../../../../nls.js'; -import { ChatAgentLocation } from '../../common/constants.js'; +import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { ChatMode } from '../../common/chatModes.js'; -import { ChatModeKind } from '../../common/constants.js'; import { IChatModelReference, IChatService } from '../../common/chatService/chatService.js'; +import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { ChatWidget } from '../widget/chatWidget.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; import { IAgentSession, AgentSessionStatus } from '../agentSessions/agentSessionsModel.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ISessionRouter, IRoutableSession, ISessionRouteResult } from '../../common/sessionRouter.js'; import { IChatInputWindowService, ChatInputWindowStorageKeys, CHAT_INPUT_WINDOW_DEFAULT_WIDTH, CHAT_INPUT_WINDOW_DEFAULT_HEIGHT } from '../../common/chatInputWindow.js'; @@ -67,8 +69,12 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private readonly _ownershipChannel: BroadcastChannel; private _widget: ChatWidget | undefined; private _modelRef: IChatModelReference | undefined; - /** Sessions loaded or spawned by routing; disposed when the window closes. */ - private readonly _routedSessionRefs: IChatModelReference[] = []; + /** Sessions loaded or spawned by routing, deduped by resource; disposed when the window closes. */ + private readonly _routedSessionRefs = new ResourceMap(); + /** In-flight `openWindow()` operation, so concurrent toggles stay idempotent. */ + private _openOperation: Promise | undefined; + /** Cancellation for the in-flight submission; canceled when the window closes. */ + private readonly _submitCts = this._register(new MutableDisposable()); get isOpen(): boolean { return !!this._window; @@ -98,12 +104,11 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind this._register({ dispose: () => ownershipChannel.close() }); this._ownershipChannel = ownershipChannel; - const onBeforeUnload = () => { + this._register(dom.addDisposableListener(mainWindow, 'beforeunload', () => { if (this._window) { this.closeWindow(); } - }; - this._register(dom.addDisposableListener(mainWindow, 'beforeunload', onBeforeUnload)); + })); const wasOpen = this.storageService.getBoolean(ChatInputWindowStorageKeys.WindowOpen, StorageScope.WORKSPACE, false); if (wasOpen) { @@ -115,7 +120,19 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind if (this._window) { return; } + // Coalesce concurrent open/toggle calls so we never create two aux windows. + if (this._openOperation) { + return this._openOperation; + } + this._openOperation = this._doOpenWindow(); + try { + await this._openOperation; + } finally { + this._openOperation = undefined; + } + } + private async _doOpenWindow(): Promise { const bounds = this._defaultBounds(); const auxiliaryWindow = await this.auxiliaryWindowService.open({ @@ -134,33 +151,57 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind const workspace = this.workspaceContextService.getWorkspace(); const projectName = workspace.folders.length > 0 ? workspace.folders[0].name : ''; - auxiliaryWindow.window.document.title = projectName ? localize('chatInputWindow.titleWithProject', "Chat Input — {0}", projectName) : localize('chatInputWindow.title', "Chat Input"); + auxiliaryWindow.window.document.title = projectName + ? localize('chatInputWindow.titleWithProject', "Chat Input — {0}", projectName) + : localize('chatInputWindow.title', "Chat Input"); auxiliaryWindow.container.style.overflow = 'hidden'; auxiliaryWindow.window.document.body.style.setProperty('margin', '0', 'important'); - // Resolve theme colors so the aux window matches the chat input box. - const theme = this.themeService.getColorTheme(); - const bgColor = theme.getColor(editorBackground)?.toString() ?? '#1e1e1e'; - const inputBg = theme.getColor(inputBackground)?.toString() ?? '#3C3C3C'; - const inputBd = theme.getColor(inputBorder)?.toString() ?? 'transparent'; - - auxiliaryWindow.container.style.setProperty('--vscode-chat-input-window-background', bgColor); - auxiliaryWindow.container.style.backgroundColor = inputBg; - auxiliaryWindow.container.style.border = `1px solid ${inputBd}`; - auxiliaryWindow.container.style.boxSizing = 'border-box'; - auxiliaryWindow.window.document.body.style.setProperty('background-color', inputBg, 'important'); - this._windowDisposables.clear(); + // Resolve theme colors so the aux window matches the chat input box, and + // re-apply them on theme changes (a light/dark/high-contrast switch would + // otherwise leave the window on the old inline colors). + const applyThemeColors = () => { + const theme = this.themeService.getColorTheme(); + const bgColor = theme.getColor(editorBackground)?.toString() ?? '#1e1e1e'; + const inputBg = theme.getColor(inputBackground)?.toString() ?? '#3C3C3C'; + const inputBd = theme.getColor(inputBorder)?.toString() ?? 'transparent'; + + auxiliaryWindow.container.style.setProperty('--vscode-chat-input-window-background', bgColor); + auxiliaryWindow.container.style.backgroundColor = inputBg; + auxiliaryWindow.container.style.border = `1px solid ${inputBd}`; + auxiliaryWindow.container.style.boxSizing = 'border-box'; + auxiliaryWindow.window.document.body.style.setProperty('background-color', inputBg, 'important'); + }; + applyThemeColors(); + this._windowDisposables.add(this.themeService.onDidColorThemeChange(() => applyThemeColors())); + + // A frameless window can only be dragged through a `-webkit-app-region: + // drag` region, so add a dedicated handle strip above the input. Its + // interactive descendants are marked no-drag inside the widget below. + const dragHandle = dom.append(auxiliaryWindow.container, dom.$('.chat-input-window-drag-handle')); + dragHandle.style.setProperty('-webkit-app-region', 'drag'); + dragHandle.style.height = '6px'; + dragHandle.style.width = '100%'; + dragHandle.style.flexShrink = '0'; + dragHandle.style.cursor = 'grab'; + auxiliaryWindow.container.style.display = 'flex'; + auxiliaryWindow.container.style.flexDirection = 'column'; + // Host the real chat input (dictation, voice mode, glow) by rendering a // compact ChatWidget. The response list is filtered out so only the input // box shows. Submission is intercepted via submitHandler (the routing - // seam) and currently dispatched to this window's dedicated session. + // seam) and routed to the best-matching existing session. this._renderChatWidget(auxiliaryWindow); - // Clean up when the user closes the window via OS controls. + // Clean up when the user closes the window via OS controls. Guard by window + // identity so a stale unload after a quick reopen can't tear down the new one. Event.once(auxiliaryWindow.onUnload)(() => { + if (this._window !== auxiliaryWindow) { + return; + } this._disposeWidget(); this._window = undefined; this._windowDisposables.clear(); @@ -178,6 +219,10 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind this.storageService.store(ChatInputWindowStorageKeys.WindowOpen, false, StorageScope.WORKSPACE, StorageTarget.MACHINE); + // Cancel any in-flight submission so routing can't dispatch after close. + this._submitCts.value?.cancel(); + this._submitCts.clear(); + this._disposeWidget(); this._window = undefined; this._windowDisposables.clear(); @@ -200,13 +245,18 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind // `monaco-workbench` class, so we only need the `.interactive-session` // wrapper here. const parent = dom.append(auxiliaryWindow.container, dom.$('.interactive-session')); - parent.style.height = '100%'; + parent.style.flex = '1 1 auto'; + parent.style.minHeight = '0'; parent.style.width = '100%'; + const scopedContextKeyService = this._windowDisposables.add(this.contextKeyService.createScoped(parent)); + // Mark this surface so its dedicated accessibility help (routing + how to + // close) takes precedence over the generic Quick Chat help. + ChatContextKeys.inChatInputWindow.bindTo(scopedContextKeyService).set(true); const scopedInstantiationService = this._windowDisposables.add(this.instantiationService.createChild( new ServiceCollection([ IContextKeyService, - this._windowDisposables.add(this.contextKeyService.createScoped(parent)), + scopedContextKeyService, ]) )); @@ -222,11 +272,11 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind filter: () => false, enableImplicitContext: false, defaultMode: ChatMode.Ask, - menus: { inputSideToolbar: MenuId.ChatInputSide, telemetrySource: 'chatInputWindow' }, - // Routing seam: intercept submission before local execution. For - // now dispatch to this window's dedicated session; ISessionRouter - // will replace this to fan out to the best-matching session. - submitHandler: (query, mode) => this._handleSubmit(query, mode), + menus: { inputSideToolbar: MenuId.ChatInputWindowSide, telemetrySource: 'chatInputWindow' }, + // Routing seam: intercept submission before local execution and + // route it to the best-matching existing session (or a new one), + // forwarding any explicit attachments on the input. + submitHandler: (query, mode, attachedContext) => this._handleSubmit(query, mode, attachedContext), }, { inputEditorBackground: inputBackground, @@ -258,34 +308,47 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind * Always returns `true` (handled) so the input-only widget never runs the * request on its own scratch session. */ - private async _handleSubmit(query: string, _mode: ChatModeKind): Promise { + private async _handleSubmit(query: string, _mode: ChatModeKind, attachedContext?: IChatRequestVariableEntry[]): Promise { const utterance = query.trim(); if (!utterance) { return false; } - const candidates = this._collectCandidateSessions(); + // Window-scoped cancellation: replacing the value disposes any previous + // source, and closing the window cancels the in-flight submission so we + // never dispatch or mutate state after teardown. + const cts = new CancellationTokenSource(); + this._submitCts.value = cts; + const token = cts.token; + + const candidates = await this._collectCandidateSessions(token); + if (token.isCancellationRequested) { + return true; + } if (!candidates.length) { // Nothing to route to — this is the first request; make a new session. - return this._dispatchToNewSession(utterance); + return this._dispatchToNewSession(query, utterance, attachedContext, token); } - const cts = new CancellationTokenSource(); let results: ISessionRouteResult[]; try { - results = await this.sessionRouter.route({ utterance, sessions: candidates }, cts.token); + results = await this.sessionRouter.route({ utterance, sessions: candidates }, token); } catch (err) { + if (token.isCancellationRequested) { + return true; + } this.logService.warn('[chatInputWindow] session routing failed:', err); - return this._dispatchToNewSession(utterance); - } finally { - cts.dispose(); + return this._dispatchToNewSession(query, utterance, attachedContext, token); + } + if (token.isCancellationRequested) { + return true; } const top = results[0]; // State 1: low confidence across the board → new session. if (!top || top.confidence < ROUTE_CONFIDENCE_THRESHOLD) { - return this._dispatchToNewSession(utterance); + return this._dispatchToNewSession(query, utterance, attachedContext, token); } // Candidates that are both above threshold and close to the top match. @@ -296,27 +359,38 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind if (closeMatches.length >= 2) { const labelById = new Map(candidates.map(c => [c.sessionId, c.label])); const choice = await this._promptSessionChoice(closeMatches, labelById); + if (token.isCancellationRequested) { + return true; + } if (choice === undefined) { // User dismissed the picker — leave the input untouched to retry. return true; } if (choice === 'new') { - return this._dispatchToNewSession(utterance); + return this._dispatchToNewSession(query, utterance, attachedContext, token); } - return this._dispatchToSession(choice, utterance); + return this._dispatchToSession(choice, query, utterance, attachedContext, token); } // State 3: a single clear winner → dispatch directly. - return this._dispatchToSession(top.sessionId, utterance); + return this._dispatchToSession(top.sessionId, query, utterance, attachedContext, token); } /** * Snapshot the current agent sessions as routing candidates, excluding this - * window's own scratch session so it can never route to itself. + * window's own scratch session so it can never route to itself. Awaits the + * session model so a pending first-load/refresh isn't missed. */ - private _collectCandidateSessions(): IRoutableSession[] { + private async _collectCandidateSessions(token: CancellationToken): Promise { + try { + await this.agentSessionsService.model.resolve(undefined); + } catch (err) { + this.logService.warn('[chatInputWindow] resolving agent sessions failed:', err); + } + if (token.isCancellationRequested) { + return []; + } const ownResource = this._modelRef?.object.sessionResource.toString(); - this.agentSessionsService.model.resolve(undefined); return this.agentSessionsService.model.sessions .filter(session => session.resource.toString() !== ownResource) .map(session => this._toRoutableSession(session)); @@ -345,7 +419,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind sessionId: match.sessionId, })); items.push({ - label: localize('chatInputWindow.newSession', "$(add) Start a new session"), + label: `$(add) ${localize('chatInputWindow.newSession', "Start a new session")}`, isNew: true, }); @@ -358,63 +432,106 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind return picked.isNew ? 'new' : picked.sessionId; } - private async _dispatchToSession(sessionId: string, utterance: string): Promise { + private async _dispatchToSession(sessionId: string, submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken): Promise { let target: URI; try { target = URI.parse(sessionId); } catch (err) { this.logService.warn('[chatInputWindow] invalid session id for routing:', sessionId, err); - return this._dispatchToNewSession(utterance); + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); } - const cts = new CancellationTokenSource(); try { - const ref = await this.chatService.acquireOrLoadSession(target, ChatAgentLocation.Chat, cts.token, 'chatInputWindow-route'); + const ref = await this.chatService.acquireOrLoadSession(target, ChatAgentLocation.Chat, token, 'chatInputWindow-route'); + if (token.isCancellationRequested) { + ref?.dispose(); + return true; + } if (!ref) { this.logService.warn('[chatInputWindow] could not load routed session, starting a new one:', sessionId); - return this._dispatchToNewSession(utterance); + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); + } + this._retainSessionRef(target, ref); + const result = await this.chatService.sendRequest(target, utterance, attachedContext?.length ? { attachedContext } : undefined); + if (token.isCancellationRequested) { + return true; } - this._routedSessionRefs.push(ref); - const result = await this.chatService.sendRequest(target, utterance); if (!result || result.kind === 'rejected') { this.logService.warn('[chatInputWindow] routed session rejected the request, starting a new one:', sessionId); - return this._dispatchToNewSession(utterance); + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); } - this._widget?.inputEditor.setValue(''); + this._clearInputIfUnchanged(submittedInput); return true; } catch (err) { + if (token.isCancellationRequested) { + return true; + } this.logService.warn('[chatInputWindow] error dispatching to routed session, starting a new one:', err); - return this._dispatchToNewSession(utterance); - } finally { - cts.dispose(); + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); } } - private async _dispatchToNewSession(utterance: string): Promise { + private async _dispatchToNewSession(submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken): Promise { try { const ref = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { debugOwner: 'chatInputWindow-new' }); - this._routedSessionRefs.push(ref); - const result = await this.chatService.sendRequest(ref.object.sessionResource, utterance); + if (token.isCancellationRequested) { + ref.dispose(); + return true; + } + this._retainSessionRef(ref.object.sessionResource, ref); + const result = await this.chatService.sendRequest(ref.object.sessionResource, utterance, attachedContext?.length ? { attachedContext } : undefined); + if (token.isCancellationRequested) { + return true; + } if (!result || result.kind === 'rejected') { this.logService.warn('[chatInputWindow] new session rejected the request, running locally'); return false; } - this._widget?.inputEditor.setValue(''); + this._clearInputIfUnchanged(submittedInput); return true; } catch (err) { + if (token.isCancellationRequested) { + return true; + } this.logService.warn('[chatInputWindow] error starting a new session, running locally:', err); return false; } } + /** + * Retain at most one reference per session resource so a long-lived window + * doesn't accumulate model references (and their sessions) as more requests + * are routed to the same target. + */ + private _retainSessionRef(resource: URI, ref: IChatModelReference): void { + if (this._routedSessionRefs.has(resource)) { + ref.dispose(); + return; + } + this._routedSessionRefs.set(resource, ref); + } + + /** + * Clear the input (and its explicit attachments) only if the editor still + * holds exactly what was submitted, so a newer draft typed while the request + * was in flight is preserved. + */ + private _clearInputIfUnchanged(submittedInput: string): void { + const editor = this._widget?.inputEditor; + if (editor && editor.getValue() === submittedInput) { + editor.setValue(''); + this._widget?.attachmentModel.clear(); + } + } + private _disposeWidget(): void { this._widget = undefined; this._modelRef?.dispose(); this._modelRef = undefined; - for (const ref of this._routedSessionRefs) { + for (const ref of this._routedSessionRefs.values()) { ref.dispose(); } - this._routedSessionRefs.length = 0; + this._routedSessionRefs.clear(); } private _defaultBounds(): IRectangle { diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts index b62256f8feb03e..257692667948bf 100644 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../../base/common/errors.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { ChatMessageRole, getTextResponseFromStream, IChatMessage, ILanguageModelsService } from '../../common/languageModels.js'; import { buildRouterMessages, heuristicScore, ISessionRouteRequest, ISessionRouteResult, ISessionRouter, parseRouterResponse } from '../../common/sessionRouter.js'; @@ -57,6 +58,11 @@ export class SessionRouterService implements ISessionRouter { const validIds = new Set(request.sessions.map(session => session.sessionId)); return parseRouterResponse(text, validIds); } catch (err) { + // Preserve cancellation semantics: a canceled token must reject so the + // caller can abort routing, rather than silently degrading to the heuristic. + if (token.isCancellationRequested) { + throw new CancellationError(); + } this.logService.trace('[SessionRouter] scoring request failed, falling back to heuristic', err); return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index dbceb183b80928..f976e089e6297c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -2675,7 +2675,8 @@ export class ChatWidget extends Disposable implements IChatWidget { // Check if a custom submit handler wants to handle this submission if (this.viewOptions.submitHandler) { const inputValue = !query ? this.getInput() : query.query; - const handled = await this.viewOptions.submitHandler(inputValue, this.input.currentModeKind); + const attachedContext = this.input.getAttachedContext().asArray(); + const handled = await this.viewOptions.submitHandler(inputValue, this.input.currentModeKind, attachedContext); if (handled) { return; } diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 25fc4f41321299..774903806dd126 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -110,6 +110,7 @@ export namespace ChatContextKeys { export const inputHasAgent = new RawContextKey('chatInputHasAgent', false); export const location = new RawContextKey('chatLocation', undefined); export const inQuickChat = new RawContextKey('quickChatHasFocus', false, { type: 'boolean', description: localize('inQuickChat', "True when the quick chat UI has focus, false otherwise.") }); + export const inChatInputWindow = new RawContextKey('inChatInputWindow', false, { type: 'boolean', description: localize('inChatInputWindow', "True when focus is in the floating chat input window, false otherwise.") }); export const inAgentSessionsWelcome = new RawContextKey('inAgentSessionsWelcome', false, { type: 'boolean', description: localize('inAgentSessionsWelcome', "True when the chat input is within the agent sessions welcome page.") }); export const inAutomationsDialog = new RawContextKey('inAutomationsDialog', false, { type: 'boolean', description: localize('inAutomationsDialog', "True when the chat input is within the automations dialog.") }); export const chatSessionType = new RawContextKey('chatSessionType', '', { type: 'string', description: localize('chatSessionType', "The type of the current chat session.") }); diff --git a/src/vs/workbench/contrib/chat/common/sessionRouter.ts b/src/vs/workbench/contrib/chat/common/sessionRouter.ts index 54ec8b1401e5da..81d8870fac5252 100644 --- a/src/vs/workbench/contrib/chat/common/sessionRouter.ts +++ b/src/vs/workbench/contrib/chat/common/sessionRouter.ts @@ -59,7 +59,7 @@ export interface ISessionRouter { route(request: ISessionRouteRequest, token: CancellationToken): Promise; } -// ─── Prompt + parsing helpers (pure; reused by any scoring backend) ────────── +// --- Prompt + parsing helpers (pure; reused by any scoring backend) --- /** A provider-agnostic chat message used to prompt the scoring model. */ export interface ISessionRouterMessage { @@ -151,23 +151,45 @@ export function parseRouterResponse(text: string, validSessionIds: ReadonlySet { - const haystack = new Set(tokenize([session.label, session.repo, session.cwd].filter(isNonEmpty).join(' '))); - if (!terms.length || !haystack.size) { + if (!terms.size) { return { sessionId: session.sessionId, confidence: 0 }; } - let hits = 0; - for (const term of terms) { - if (haystack.has(term)) { - hits++; + const fields = [session.label, session.repo, session.cwd].filter(isNonEmpty); + let bestRecall = 0; + const matchedTerms = new Set(); + for (const field of fields) { + const fieldTokens = new Set(tokenize(field)); + if (!fieldTokens.size) { + continue; } + let fieldHits = 0; + for (const token of fieldTokens) { + if (terms.has(token)) { + fieldHits++; + matchedTerms.add(token); + } + } + bestRecall = Math.max(bestRecall, fieldHits / fieldTokens.size); + } + if (!matchedTerms.size) { + return { sessionId: session.sessionId, confidence: 0 }; } - return { sessionId: session.sessionId, confidence: hits / terms.length }; + const precision = matchedTerms.size / terms.size; + const confidence = 0.75 * bestRecall + 0.25 * precision; + return { sessionId: session.sessionId, confidence }; }); results.sort((a, b) => b.confidence - a.confidence); return results; diff --git a/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts b/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts index 28c64ff128feb0..dd7c9611833bb8 100644 --- a/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts @@ -48,4 +48,17 @@ suite('SessionRouter helpers', () => { assert.strictEqual(ranked[0].sessionId, 's1'); assert.ok(ranked[0].confidence > ranked[1].confidence); }); + + test('heuristicScore gives an obvious label match a routable (>= 0.5) confidence', () => { + const ROUTE_CONFIDENCE_THRESHOLD = 0.5; + const ranked = heuristicScore({ + utterance: 'can you keep working on the voice narration session please', + sessions: [ + { sessionId: 's1', label: 'voice narration', repo: 'meganrogge/momentum-map', status: 'idle' }, + { sessionId: 's2', label: 'docs cleanup', repo: 'microsoft/vscode-docs' } + ] + }); + assert.strictEqual(ranked[0].sessionId, 's1'); + assert.ok(ranked[0].confidence >= ROUTE_CONFIDENCE_THRESHOLD, `expected >= ${ROUTE_CONFIDENCE_THRESHOLD}, got ${ranked[0].confidence}`); + }); }); From f79287b7d0549d13e8fc2754167352a161d77d77 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 22 Jul 2026 12:40:23 -0400 Subject: [PATCH 05/11] Pin session router to copilot-utility-small model The session router scores candidate sessions as a background classification task. Pin it to the small utility model (vendor: copilot, id: copilot-utility-small) like other internal utility features (chatGoalSummaryService, chatToolRiskAssessmentService, etc.) instead of grabbing the first available Copilot model, which could be an expensive premium model and is ordering-dependent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ece96f2d-c878-4a57-97c5-884542b8581e --- .../chat/browser/sessionRouter/sessionRouterService.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts index 257692667948bf..2e1cc6c5ca41db 100644 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts @@ -38,7 +38,10 @@ export class SessionRouterService implements ISessionRouter { private async scoreWithModel(request: ISessionRouteRequest, token: CancellationToken): Promise { let modelId: string | undefined; try { - const models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot' }); + // Use the small utility model for this background scoring task, matching + // other internal utility features (e.g. chatGoalSummaryService, + // chatToolRiskAssessmentService) rather than consuming a premium model. + const models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-utility-small' }); modelId = models.at(0); } catch (err) { this.logService.trace('[SessionRouter] model selection failed, falling back to heuristic', err); From 062d4f6775b8ffee63c936d188290b2caba10332 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 22 Jul 2026 16:09:55 -0400 Subject: [PATCH 06/11] Chat input window: advisory badge with auto-send instead of silent routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing is no longer silent. After scoring the utterance against existing sessions, the window resolves a single pending target (top match above the confidence threshold, biased toward the last-used session; otherwise a new session) and shows an inline badge naming that target with a short countdown. When the countdown elapses the request auto-sends. The user can redirect it ("Change"), abort it ("Cancel"), or just keep typing — any edit cancels the auto-send — so a request is never dispatched without a visible, correctable step. - Add ROUTE_AUTOSEND_DELAY_MS countdown + pending-send badge in the aux window. - Remember the last routed session (workspace storage) and pre-select it. - Generalize the target picker to list all scored candidates with a preselected entry, reused by the badge's "Change" action. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ece96f2d-c878-4a57-97c5-884542b8581e --- .../chatInputWindow/chatInputWindowService.ts | 283 ++++++++++++++---- .../chatInputWindow/media/chatInputWindow.css | 51 ++++ .../contrib/chat/common/chatInputWindow.ts | 1 + 3 files changed, 280 insertions(+), 55 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index 98202d0fe61354..26099bd6a27d7f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { CancellationTokenSource, CancellationToken } from '../../../../../base/common/cancellation.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { URI } from '../../../../../base/common/uri.js'; import { mainWindow } from '../../../../../base/browser/window.js'; @@ -36,9 +37,11 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ISessionRouter, IRoutableSession, ISessionRouteResult } from '../../common/sessionRouter.js'; import { IChatInputWindowService, ChatInputWindowStorageKeys, CHAT_INPUT_WINDOW_DEFAULT_WIDTH, CHAT_INPUT_WINDOW_DEFAULT_HEIGHT } from '../../common/chatInputWindow.js'; +import './media/chatInputWindow.css'; + /** * Minimum confidence for a candidate to be treated as a real match. Below this - * for every candidate, the request starts a brand-new session instead. + * for every candidate, the request targets a brand-new session instead. */ const ROUTE_CONFIDENCE_THRESHOLD = 0.5; @@ -51,6 +54,18 @@ const ROUTE_AMBIGUITY_MARGIN = 0.2; /** Maximum number of options shown in the disambiguation picker. */ const ROUTE_MAX_CHOICES = 6; +/** + * How long the pending-send badge counts down before auto-dispatching to the + * routed target. Long enough to read the target and intervene, short enough to + * keep a hands-free/voice flow moving. + */ +const ROUTE_AUTOSEND_DELAY_MS = 3000; + +/** Resolved destination for a submitted request: an existing session or a new one. */ +type PendingTarget = + | { readonly kind: 'session'; readonly sessionId: string; readonly label: string; readonly confidence: number } + | { readonly kind: 'new'; readonly label: string }; + /** * Hosts a frameless, always-on-top auxiliary window containing (eventually) the * full chat input box — dictation, voice mode, and the glow animation. Step 1 @@ -69,6 +84,10 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private readonly _ownershipChannel: BroadcastChannel; private _widget: ChatWidget | undefined; private _modelRef: IChatModelReference | undefined; + /** Parent element hosting the input widget; badge is inserted just before it. */ + private _widgetParent: HTMLElement | undefined; + /** Active pending-send badge + auto-send timers; replaced/cleared per submission. */ + private readonly _pendingSend = this._register(new MutableDisposable()); /** Sessions loaded or spawned by routing, deduped by resource; disposed when the window closes. */ private readonly _routedSessionRefs = new ResourceMap(); /** In-flight `openWindow()` operation, so concurrent toggles stay idempotent. */ @@ -248,6 +267,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind parent.style.flex = '1 1 auto'; parent.style.minHeight = '0'; parent.style.width = '100%'; + this._widgetParent = parent; const scopedContextKeyService = this._windowDisposables.add(this.contextKeyService.createScoped(parent)); // Mark this surface so its dedicated accessibility help (routing + how to @@ -301,12 +321,12 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } /** - * Routing seam with three outcomes: - * 1. No confident match → start a brand-new session for the request. - * 2. Several comparable high matches → ask the user which session to use. - * 3. A single clear high match → dispatch straight to it. - * Always returns `true` (handled) so the input-only widget never runs the - * request on its own scratch session. + * Routing seam. Scores the utterance against existing sessions and resolves a + * single **pending target** (best match above threshold, else a new session), + * then shows an advisory badge that counts down and auto-sends to that target. + * Routing is never silent: the user can redirect or cancel during the + * countdown. Always returns `true` (handled) so the input-only widget never + * runs the request on its own scratch session. */ private async _handleSubmit(query: string, _mode: ChatModeKind, attachedContext?: IChatRequestVariableEntry[]): Promise { const utterance = query.trim(); @@ -314,6 +334,9 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind return false; } + // A new submission supersedes any pending badge from a previous one. + this._pendingSend.clear(); + // Window-scoped cancellation: replacing the value disposes any previous // source, and closing the window cancels the in-flight submission so we // never dispatch or mutate state after teardown. @@ -325,55 +348,56 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind if (token.isCancellationRequested) { return true; } - if (!candidates.length) { - // Nothing to route to — this is the first request; make a new session. - return this._dispatchToNewSession(query, utterance, attachedContext, token); + + const results = candidates.length ? await this._route(candidates, utterance, token) : []; + if (token.isCancellationRequested) { + return true; } - let results: ISessionRouteResult[]; + const target = this._resolveTarget(results, candidates); + this._beginPendingSend(target, results, candidates, query, utterance, attachedContext, cts); + return true; + } + + /** Run the router, degrading to an empty ranking on failure/cancellation. */ + private async _route(candidates: IRoutableSession[], utterance: string, token: CancellationToken): Promise { try { - results = await this.sessionRouter.route({ utterance, sessions: candidates }, token); + return await this.sessionRouter.route({ utterance, sessions: candidates }, token); } catch (err) { - if (token.isCancellationRequested) { - return true; + if (!token.isCancellationRequested) { + this.logService.warn('[chatInputWindow] session routing failed:', err); } - this.logService.warn('[chatInputWindow] session routing failed:', err); - return this._dispatchToNewSession(query, utterance, attachedContext, token); - } - if (token.isCancellationRequested) { - return true; + return []; } + } + /** + * Pick the single pending target the badge pre-selects: the top match if it + * clears the confidence threshold (biased toward the last-used session on a + * tie within the ambiguity margin), otherwise a brand-new session. + */ + private _resolveTarget(results: ISessionRouteResult[], candidates: IRoutableSession[]): PendingTarget { + const labelById = new Map(candidates.map(c => [c.sessionId, c.label])); const top = results[0]; - - // State 1: low confidence across the board → new session. if (!top || top.confidence < ROUTE_CONFIDENCE_THRESHOLD) { - return this._dispatchToNewSession(query, utterance, attachedContext, token); - } - - // Candidates that are both above threshold and close to the top match. - const closeMatches = results.filter(r => - r.confidence >= ROUTE_CONFIDENCE_THRESHOLD && (top.confidence - r.confidence) <= ROUTE_AMBIGUITY_MARGIN); - - // State 2: ambiguous — several comparable matches → ask the user. - if (closeMatches.length >= 2) { - const labelById = new Map(candidates.map(c => [c.sessionId, c.label])); - const choice = await this._promptSessionChoice(closeMatches, labelById); - if (token.isCancellationRequested) { - return true; - } - if (choice === undefined) { - // User dismissed the picker — leave the input untouched to retry. - return true; - } - if (choice === 'new') { - return this._dispatchToNewSession(query, utterance, attachedContext, token); - } - return this._dispatchToSession(choice, query, utterance, attachedContext, token); + return { kind: 'new', label: localize('chatInputWindow.newSession', "New session") }; } - // State 3: a single clear winner → dispatch directly. - return this._dispatchToSession(top.sessionId, query, utterance, attachedContext, token); + // Prefer the last-used session when it is within the ambiguity margin of + // the top match, so repeated turns keep landing on the same session. + const lastTargetId = this.storageService.get(ChatInputWindowStorageKeys.LastTarget, StorageScope.WORKSPACE); + const preferred = lastTargetId + ? results.find(r => r.sessionId === lastTargetId + && r.confidence >= ROUTE_CONFIDENCE_THRESHOLD + && (top.confidence - r.confidence) <= ROUTE_AMBIGUITY_MARGIN) + : undefined; + const chosen = preferred ?? top; + return { + kind: 'session', + sessionId: chosen.sessionId, + label: labelById.get(chosen.sessionId) ?? chosen.sessionId, + confidence: chosen.confidence, + }; } /** @@ -406,25 +430,38 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } /** - * Ask the user to pick a target when several sessions match with comparable - * confidence. Returns the chosen session id, `'new'` for a new session, or - * `undefined` if the picker was dismissed. + * Ask the user to pick a target, listing the scored sessions (best first, + * capped) plus a new-session option. `preselectedId` is floated to the top so + * it is the default highlighted choice. Returns the chosen session id, + * `'new'` for a new session, or `undefined` if the picker was dismissed. */ - private async _promptSessionChoice(matches: ISessionRouteResult[], labelById: Map): Promise { + private async _promptSessionChoice(results: ISessionRouteResult[], labelById: Map, preselectedId?: string): Promise { type RouteChoiceItem = IQuickPickItem & { sessionId?: string; isNew?: boolean }; - const items: RouteChoiceItem[] = matches.slice(0, ROUTE_MAX_CHOICES).map(match => ({ + const ordered = results.slice(0, ROUTE_MAX_CHOICES); + if (preselectedId && preselectedId !== 'new') { + const idx = ordered.findIndex(r => r.sessionId === preselectedId); + if (idx > 0) { + ordered.unshift(ordered.splice(idx, 1)[0]); + } + } + const items: RouteChoiceItem[] = ordered.map(match => ({ label: labelById.get(match.sessionId) ?? match.sessionId, description: localize('chatInputWindow.matchPercent', "{0}% match", Math.round(match.confidence * 100)), detail: match.reason, sessionId: match.sessionId, })); - items.push({ - label: `$(add) ${localize('chatInputWindow.newSession', "Start a new session")}`, + const newItem: RouteChoiceItem = { + label: `$(add) ${localize('chatInputWindow.newSession', "New session")}`, isNew: true, - }); + }; + if (preselectedId === 'new') { + items.unshift(newItem); + } else { + items.push(newItem); + } const picked = await this.quickInputService.pick(items, { - placeHolder: localize('chatInputWindow.choosePlaceholder', "Multiple sessions match — choose where to send this request"), + placeHolder: localize('chatInputWindow.choosePlaceholder', "Choose where to send this request"), }); if (!picked) { return undefined; @@ -432,6 +469,138 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind return picked.isNew ? 'new' : picked.sessionId; } + /** + * Show the advisory pending-send badge and start the auto-send countdown. + * The badge names the routed target and counts down; when it elapses the + * request is dispatched. The user can redirect ("Change"), abort ("Cancel"), + * or simply keep typing (which cancels the auto-send) before it fires. + */ + private _beginPendingSend( + target: PendingTarget, + results: ISessionRouteResult[], + candidates: IRoutableSession[], + submittedInput: string, + utterance: string, + attachedContext: IChatRequestVariableEntry[] | undefined, + cts: CancellationTokenSource, + ): void { + const parent = this._widgetParent; + const container = this._window?.container; + if (!parent || !container) { + // No surface to host the badge — fall back to an immediate dispatch. + void this._dispatchTo(target, submittedInput, utterance, attachedContext, cts.token); + return; + } + + const store = new DisposableStore(); + const targetWindow = dom.getWindow(container); + let current = target; + + const badge = dom.$('.chat-input-window-pending'); + const label = dom.append(badge, dom.$('span.chat-input-window-pending-label')); + const countdownEl = dom.append(badge, dom.$('span.chat-input-window-pending-countdown')); + const changeEl = dom.append(badge, dom.$('a.chat-input-window-pending-action', { role: 'button', tabindex: '0' })); + changeEl.textContent = localize('chatInputWindow.change', "Change"); + const cancelEl = dom.append(badge, dom.$('a.chat-input-window-pending-action', { role: 'button', tabindex: '0' })); + cancelEl.textContent = localize('chatInputWindow.cancel', "Cancel"); + container.insertBefore(badge, parent); + store.add(toDisposable(() => badge.remove())); + + const renderLabel = () => { + label.textContent = current.kind === 'session' + ? localize('chatInputWindow.sendingToSession', "Sending to {0} · {1}% match", current.label, Math.round(current.confidence * 100)) + : localize('chatInputWindow.sendingToNew', "Sending to {0}", current.label); + }; + renderLabel(); + + let remainingSeconds = Math.ceil(ROUTE_AUTOSEND_DELAY_MS / 1000); + const renderCountdown = () => { + countdownEl.textContent = localize('chatInputWindow.sendingIn', "sending in {0}s", remainingSeconds); + }; + + const send = () => { + // Detach the badge (and its listeners) before dispatch so a clear of + // the input during send can't re-enter cancel(). + this._pendingSend.clear(); + void this._dispatchTo(current, submittedInput, utterance, attachedContext, cts.token); + }; + + // Countdown lives in a MutableDisposable so it can be paused while the + // "Change" picker is open and restarted afterwards. + const countdownTimer = store.add(new MutableDisposable()); + const startCountdown = () => { + remainingSeconds = Math.ceil(ROUTE_AUTOSEND_DELAY_MS / 1000); + renderCountdown(); + const handle = targetWindow.setInterval(() => { + remainingSeconds--; + if (remainingSeconds <= 0) { + send(); + return; + } + renderCountdown(); + }, 1000); + countdownTimer.value = toDisposable(() => targetWindow.clearInterval(handle)); + }; + + const cancel = () => { + cts.cancel(); + this._pendingSend.clear(); + }; + store.add(dom.addDisposableListener(cancelEl, dom.EventType.CLICK, cancel)); + store.add(dom.addStandardDisposableListener(cancelEl, dom.EventType.KEY_DOWN, e => { + if (e.equals(KeyCode.Enter) || e.equals(KeyCode.Space)) { + e.preventDefault(); + cancel(); + } + })); + + const change = async () => { + countdownTimer.clear(); + const labelById = new Map(candidates.map(c => [c.sessionId, c.label])); + const preselected = current.kind === 'session' ? current.sessionId : 'new'; + const choice = await this._promptSessionChoice(results, labelById, preselected); + if (cts.token.isCancellationRequested || this._pendingSend.value !== store) { + return; + } + if (choice === undefined) { + startCountdown(); + return; + } + if (choice === 'new') { + current = { kind: 'new', label: localize('chatInputWindow.newSession', "New session") }; + } else { + const match = results.find(r => r.sessionId === choice); + current = { kind: 'session', sessionId: choice, label: labelById.get(choice) ?? choice, confidence: match?.confidence ?? 0 }; + } + renderLabel(); + startCountdown(); + }; + store.add(dom.addDisposableListener(changeEl, dom.EventType.CLICK, () => void change())); + store.add(dom.addStandardDisposableListener(changeEl, dom.EventType.KEY_DOWN, e => { + if (e.equals(KeyCode.Enter) || e.equals(KeyCode.Space)) { + e.preventDefault(); + void change(); + } + })); + + // Typing in the input cancels the auto-send so an edit never silently sends. + const editor = this._widget?.inputEditor; + if (editor) { + store.add(editor.onDidChangeModelContent(() => cancel())); + } + + this._pendingSend.value = store; + startCountdown(); + } + + /** Dispatch a resolved pending target, remembering it for next time. */ + private async _dispatchTo(target: PendingTarget, submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken): Promise { + if (target.kind === 'new') { + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); + } + return this._dispatchToSession(target.sessionId, submittedInput, utterance, attachedContext, token); + } + private async _dispatchToSession(sessionId: string, submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken): Promise { let target: URI; try { @@ -460,6 +629,8 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind this.logService.warn('[chatInputWindow] routed session rejected the request, starting a new one:', sessionId); return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); } + // Remember this session so the next request biases toward it. + this.storageService.store(ChatInputWindowStorageKeys.LastTarget, sessionId, StorageScope.WORKSPACE, StorageTarget.MACHINE); this._clearInputIfUnchanged(submittedInput); return true; } catch (err) { @@ -525,7 +696,9 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } private _disposeWidget(): void { + this._pendingSend.clear(); this._widget = undefined; + this._widgetParent = undefined; this._modelRef?.dispose(); this._modelRef = undefined; for (const ref of this._routedSessionRefs.values()) { diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css b/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css new file mode 100644 index 00000000000000..d4b7e86357061c --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-input-window-pending { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + box-sizing: border-box; + padding: 2px 10px; + overflow: hidden; + font-size: 12px; + line-height: 20px; + color: var(--vscode-foreground); + background-color: var(--vscode-editorWidget-background); + border-bottom: 1px solid var(--vscode-editorWidget-border, transparent); +} + +.chat-input-window-pending .chat-input-window-pending-label { + flex: 1 1 auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-input-window-pending .chat-input-window-pending-countdown { + color: var(--vscode-descriptionForeground); + font-variant-numeric: tabular-nums; +} + +.chat-input-window-pending .chat-input-window-pending-action { + flex-shrink: 0; + cursor: pointer; + color: var(--vscode-textLink-foreground); + /* Frameless-window drag regions swallow clicks; keep actions interactive. */ + -webkit-app-region: no-drag; +} + +.chat-input-window-pending .chat-input-window-pending-action:hover { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; +} + +.chat-input-window-pending .chat-input-window-pending-action:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; + border-radius: var(--vscode-cornerRadius-small, 2px); +} diff --git a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts index bf41d16ff46a17..e089be12ebf09c 100644 --- a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts +++ b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts @@ -17,6 +17,7 @@ export const CHAT_INPUT_WINDOW_DEFAULT_HEIGHT = 110; */ export const enum ChatInputWindowStorageKeys { WindowOpen = 'chatInputWindow.windowOpen', + LastTarget = 'chatInputWindow.lastTarget', } export const IChatInputWindowService = createDecorator('chatInputWindowService'); From 5a14efb85f0b9f316db9323b699b520d029647c9 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 22 Jul 2026 16:37:33 -0400 Subject: [PATCH 07/11] Omni routing: extract shared badge controller, add chat.omni.enabled, wire Quick Chat Extracts the advisory-badge session routing from ChatInputWindowService into a reusable ChatSessionRoutingController (shared CSS with generic class names) and wires Quick Chat to it behind a new `chat.omni.enabled` setting. - New `chat.omni.enabled` (experimental, default off) gates the omni experience on omni surfaces such as Quick Chat. - No-match case no longer delays: it creates and sends to a new chat immediately and the badge links to that session (via IChatWidgetService.openSession) as soon as it exists. - Confident-match case keeps the countdown + Change/Cancel/type-to-cancel flow. - The floating input window now delegates to the shared controller. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ece96f2d-c878-4a57-97c5-884542b8581e --- .../chat/browser/chat.shared.contribution.ts | 6 + .../chatInputWindow/chatInputWindowService.ts | 477 +------------- .../chatSessionRoutingController.ts | 583 ++++++++++++++++++ .../media/chatSessionRouting.css} | 12 +- .../chat/browser/widgetHosts/chatQuick.ts | 27 + .../contrib/chat/common/chatInputWindow.ts | 1 - .../contrib/chat/common/sessionRouter.ts | 6 + 7 files changed, 656 insertions(+), 456 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts rename src/vs/workbench/contrib/chat/browser/{chatInputWindow/media/chatInputWindow.css => sessionRouter/media/chatSessionRouting.css} (77%) 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 67bbc70b9f8429..2e07c0b25d2cb5 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -244,6 +244,12 @@ configurationRegistry.registerConfiguration({ tags: ['experimental'], agentsWindow: { default: true }, }, + 'chat.omni.enabled': { + type: 'boolean', + markdownDescription: nls.localize('chat.omni.enabled', "Enables the omni chat experience: when you submit from an omni surface (such as Quick Chat), the request is scored against your existing sessions and an advisory badge routes it to the best match — a confident match counts down and auto-sends (redirectable or cancelable), while no match creates and sends to a new chat and links to it."), + default: false, + tags: ['experimental'] + }, 'chat.fontSize': { type: 'number', description: nls.localize('chat.fontSize', "Controls the font size in pixels in chat messages."), diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index 26099bd6a27d7f..f1bff499ca7134 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -4,19 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; -import { Disposable, DisposableStore, MutableDisposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; -import { CancellationTokenSource, CancellationToken } from '../../../../../base/common/cancellation.js'; -import { KeyCode } from '../../../../../base/common/keyCodes.js'; -import { ResourceMap } from '../../../../../base/common/map.js'; -import { URI } from '../../../../../base/common/uri.js'; import { mainWindow } from '../../../../../base/browser/window.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IAuxiliaryWindowService, IAuxiliaryWindow } from '../../../../services/auxiliaryWindow/browser/auxiliaryWindowService.js'; import { IRectangle } from '../../../../../platform/window/common/window.js'; @@ -24,52 +19,20 @@ import { IThemeService } from '../../../../../platform/theme/common/themeService import { editorBackground } from '../../../../../platform/theme/common/colorRegistry.js'; import { inputBackground, inputBorder } from '../../../../../platform/theme/common/colors/inputColors.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; -import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; import { localize } from '../../../../../nls.js'; -import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; +import { ChatAgentLocation } from '../../common/constants.js'; import { ChatMode } from '../../common/chatModes.js'; import { IChatModelReference, IChatService } from '../../common/chatService/chatService.js'; -import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { ChatWidget } from '../widget/chatWidget.js'; -import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; -import { IAgentSession, AgentSessionStatus } from '../agentSessions/agentSessionsModel.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { ISessionRouter, IRoutableSession, ISessionRouteResult } from '../../common/sessionRouter.js'; +import { ChatSessionRoutingController, IChatSessionRoutingHost } from '../sessionRouter/chatSessionRoutingController.js'; import { IChatInputWindowService, ChatInputWindowStorageKeys, CHAT_INPUT_WINDOW_DEFAULT_WIDTH, CHAT_INPUT_WINDOW_DEFAULT_HEIGHT } from '../../common/chatInputWindow.js'; -import './media/chatInputWindow.css'; - -/** - * Minimum confidence for a candidate to be treated as a real match. Below this - * for every candidate, the request targets a brand-new session instead. - */ -const ROUTE_CONFIDENCE_THRESHOLD = 0.5; - -/** - * When two or more matches are within this confidence margin of the top match, - * the result is treated as ambiguous and the user is asked to choose. - */ -const ROUTE_AMBIGUITY_MARGIN = 0.2; - -/** Maximum number of options shown in the disambiguation picker. */ -const ROUTE_MAX_CHOICES = 6; - /** - * How long the pending-send badge counts down before auto-dispatching to the - * routed target. Long enough to read the target and intervene, short enough to - * keep a hands-free/voice flow moving. - */ -const ROUTE_AUTOSEND_DELAY_MS = 3000; - -/** Resolved destination for a submitted request: an existing session or a new one. */ -type PendingTarget = - | { readonly kind: 'session'; readonly sessionId: string; readonly label: string; readonly confidence: number } - | { readonly kind: 'new'; readonly label: string }; - -/** - * Hosts a frameless, always-on-top auxiliary window containing (eventually) the - * full chat input box — dictation, voice mode, and the glow animation. Step 1 - * opens an empty themed container so the window shell can be verified visually. + * Hosts a frameless, always-on-top auxiliary window containing the full chat + * input box — dictation, voice mode, and the glow animation. Submissions are + * intercepted and routed to the best-matching existing session (or a new one) + * via the shared {@link ChatSessionRoutingController}. */ export class ChatInputWindowService extends Disposable implements IChatInputWindowService { @@ -82,18 +45,13 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private _window: IAuxiliaryWindow | undefined; private readonly _windowDisposables = this._register(new DisposableStore()); private readonly _ownershipChannel: BroadcastChannel; - private _widget: ChatWidget | undefined; private _modelRef: IChatModelReference | undefined; - /** Parent element hosting the input widget; badge is inserted just before it. */ + /** Parent element hosting the input widget; the routing badge is inserted just before it. */ private _widgetParent: HTMLElement | undefined; - /** Active pending-send badge + auto-send timers; replaced/cleared per submission. */ - private readonly _pendingSend = this._register(new MutableDisposable()); - /** Sessions loaded or spawned by routing, deduped by resource; disposed when the window closes. */ - private readonly _routedSessionRefs = new ResourceMap(); + /** Shared routing + advisory-badge behaviour; recreated per widget, torn down on close. */ + private _routingController: ChatSessionRoutingController | undefined; /** In-flight `openWindow()` operation, so concurrent toggles stay idempotent. */ private _openOperation: Promise | undefined; - /** Cancellation for the in-flight submission; canceled when the window closes. */ - private readonly _submitCts = this._register(new MutableDisposable()); get isOpen(): boolean { return !!this._window; @@ -107,10 +65,6 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IChatService private readonly chatService: IChatService, - @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, - @ISessionRouter private readonly sessionRouter: ISessionRouter, - @IQuickInputService private readonly quickInputService: IQuickInputService, - @ILogService private readonly logService: ILogService, ) { super(); @@ -239,8 +193,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind this.storageService.store(ChatInputWindowStorageKeys.WindowOpen, false, StorageScope.WORKSPACE, StorageTarget.MACHINE); // Cancel any in-flight submission so routing can't dispatch after close. - this._submitCts.value?.cancel(); - this._submitCts.clear(); + this._routingController?.cancelPending(); this._disposeWidget(); this._window = undefined; @@ -296,7 +249,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind // Routing seam: intercept submission before local execution and // route it to the best-matching existing session (or a new one), // forwarding any explicit attachments on the input. - submitHandler: (query, mode, attachedContext) => this._handleSubmit(query, mode, attachedContext), + submitHandler: (query, mode, attachedContext) => this._routingController?.handleSubmit(query, mode, attachedContext) ?? Promise.resolve(false), }, { inputEditorBackground: inputBackground, @@ -312,7 +265,21 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind const modelRef = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { disableBackgroundKeepAlive: true, debugOwner: 'ChatInputWindow' }); this._modelRef = modelRef; widget.setModel(modelRef.object); - this._widget = widget; + + // Route submissions through the shared controller, inserting its advisory + // badge just above the input, and excluding this window's scratch session + // from the routing candidates so it can never route to itself. + const host: IChatSessionRoutingHost = { + widget, + getOwnSessionResource: () => this._modelRef?.object.sessionResource, + placeBadge: (badge) => { + const container = this._window?.container; + if (container && this._widgetParent) { + container.insertBefore(badge, this._widgetParent); + } + }, + }; + this._routingController = this._windowDisposables.add(this.instantiationService.createInstance(ChatSessionRoutingController, host, 'chatInputWindow')); const layout = () => widget.layout(parent.offsetHeight, parent.offsetWidth); layout(); @@ -320,391 +287,11 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind this._windowDisposables.add(widget.onDidChangeHeight(() => layout())); } - /** - * Routing seam. Scores the utterance against existing sessions and resolves a - * single **pending target** (best match above threshold, else a new session), - * then shows an advisory badge that counts down and auto-sends to that target. - * Routing is never silent: the user can redirect or cancel during the - * countdown. Always returns `true` (handled) so the input-only widget never - * runs the request on its own scratch session. - */ - private async _handleSubmit(query: string, _mode: ChatModeKind, attachedContext?: IChatRequestVariableEntry[]): Promise { - const utterance = query.trim(); - if (!utterance) { - return false; - } - - // A new submission supersedes any pending badge from a previous one. - this._pendingSend.clear(); - - // Window-scoped cancellation: replacing the value disposes any previous - // source, and closing the window cancels the in-flight submission so we - // never dispatch or mutate state after teardown. - const cts = new CancellationTokenSource(); - this._submitCts.value = cts; - const token = cts.token; - - const candidates = await this._collectCandidateSessions(token); - if (token.isCancellationRequested) { - return true; - } - - const results = candidates.length ? await this._route(candidates, utterance, token) : []; - if (token.isCancellationRequested) { - return true; - } - - const target = this._resolveTarget(results, candidates); - this._beginPendingSend(target, results, candidates, query, utterance, attachedContext, cts); - return true; - } - - /** Run the router, degrading to an empty ranking on failure/cancellation. */ - private async _route(candidates: IRoutableSession[], utterance: string, token: CancellationToken): Promise { - try { - return await this.sessionRouter.route({ utterance, sessions: candidates }, token); - } catch (err) { - if (!token.isCancellationRequested) { - this.logService.warn('[chatInputWindow] session routing failed:', err); - } - return []; - } - } - - /** - * Pick the single pending target the badge pre-selects: the top match if it - * clears the confidence threshold (biased toward the last-used session on a - * tie within the ambiguity margin), otherwise a brand-new session. - */ - private _resolveTarget(results: ISessionRouteResult[], candidates: IRoutableSession[]): PendingTarget { - const labelById = new Map(candidates.map(c => [c.sessionId, c.label])); - const top = results[0]; - if (!top || top.confidence < ROUTE_CONFIDENCE_THRESHOLD) { - return { kind: 'new', label: localize('chatInputWindow.newSession', "New session") }; - } - - // Prefer the last-used session when it is within the ambiguity margin of - // the top match, so repeated turns keep landing on the same session. - const lastTargetId = this.storageService.get(ChatInputWindowStorageKeys.LastTarget, StorageScope.WORKSPACE); - const preferred = lastTargetId - ? results.find(r => r.sessionId === lastTargetId - && r.confidence >= ROUTE_CONFIDENCE_THRESHOLD - && (top.confidence - r.confidence) <= ROUTE_AMBIGUITY_MARGIN) - : undefined; - const chosen = preferred ?? top; - return { - kind: 'session', - sessionId: chosen.sessionId, - label: labelById.get(chosen.sessionId) ?? chosen.sessionId, - confidence: chosen.confidence, - }; - } - - /** - * Snapshot the current agent sessions as routing candidates, excluding this - * window's own scratch session so it can never route to itself. Awaits the - * session model so a pending first-load/refresh isn't missed. - */ - private async _collectCandidateSessions(token: CancellationToken): Promise { - try { - await this.agentSessionsService.model.resolve(undefined); - } catch (err) { - this.logService.warn('[chatInputWindow] resolving agent sessions failed:', err); - } - if (token.isCancellationRequested) { - return []; - } - const ownResource = this._modelRef?.object.sessionResource.toString(); - return this.agentSessionsService.model.sessions - .filter(session => session.resource.toString() !== ownResource) - .map(session => this._toRoutableSession(session)); - } - - private _toRoutableSession(session: IAgentSession): IRoutableSession { - return { - sessionId: session.resource.toString(), - label: session.label, - status: statusToString(session.status), - lastActivity: session.timing?.lastRequestEnded ?? session.timing?.lastRequestStarted ?? session.timing?.created, - }; - } - - /** - * Ask the user to pick a target, listing the scored sessions (best first, - * capped) plus a new-session option. `preselectedId` is floated to the top so - * it is the default highlighted choice. Returns the chosen session id, - * `'new'` for a new session, or `undefined` if the picker was dismissed. - */ - private async _promptSessionChoice(results: ISessionRouteResult[], labelById: Map, preselectedId?: string): Promise { - type RouteChoiceItem = IQuickPickItem & { sessionId?: string; isNew?: boolean }; - const ordered = results.slice(0, ROUTE_MAX_CHOICES); - if (preselectedId && preselectedId !== 'new') { - const idx = ordered.findIndex(r => r.sessionId === preselectedId); - if (idx > 0) { - ordered.unshift(ordered.splice(idx, 1)[0]); - } - } - const items: RouteChoiceItem[] = ordered.map(match => ({ - label: labelById.get(match.sessionId) ?? match.sessionId, - description: localize('chatInputWindow.matchPercent', "{0}% match", Math.round(match.confidence * 100)), - detail: match.reason, - sessionId: match.sessionId, - })); - const newItem: RouteChoiceItem = { - label: `$(add) ${localize('chatInputWindow.newSession', "New session")}`, - isNew: true, - }; - if (preselectedId === 'new') { - items.unshift(newItem); - } else { - items.push(newItem); - } - - const picked = await this.quickInputService.pick(items, { - placeHolder: localize('chatInputWindow.choosePlaceholder', "Choose where to send this request"), - }); - if (!picked) { - return undefined; - } - return picked.isNew ? 'new' : picked.sessionId; - } - - /** - * Show the advisory pending-send badge and start the auto-send countdown. - * The badge names the routed target and counts down; when it elapses the - * request is dispatched. The user can redirect ("Change"), abort ("Cancel"), - * or simply keep typing (which cancels the auto-send) before it fires. - */ - private _beginPendingSend( - target: PendingTarget, - results: ISessionRouteResult[], - candidates: IRoutableSession[], - submittedInput: string, - utterance: string, - attachedContext: IChatRequestVariableEntry[] | undefined, - cts: CancellationTokenSource, - ): void { - const parent = this._widgetParent; - const container = this._window?.container; - if (!parent || !container) { - // No surface to host the badge — fall back to an immediate dispatch. - void this._dispatchTo(target, submittedInput, utterance, attachedContext, cts.token); - return; - } - - const store = new DisposableStore(); - const targetWindow = dom.getWindow(container); - let current = target; - - const badge = dom.$('.chat-input-window-pending'); - const label = dom.append(badge, dom.$('span.chat-input-window-pending-label')); - const countdownEl = dom.append(badge, dom.$('span.chat-input-window-pending-countdown')); - const changeEl = dom.append(badge, dom.$('a.chat-input-window-pending-action', { role: 'button', tabindex: '0' })); - changeEl.textContent = localize('chatInputWindow.change', "Change"); - const cancelEl = dom.append(badge, dom.$('a.chat-input-window-pending-action', { role: 'button', tabindex: '0' })); - cancelEl.textContent = localize('chatInputWindow.cancel', "Cancel"); - container.insertBefore(badge, parent); - store.add(toDisposable(() => badge.remove())); - - const renderLabel = () => { - label.textContent = current.kind === 'session' - ? localize('chatInputWindow.sendingToSession', "Sending to {0} · {1}% match", current.label, Math.round(current.confidence * 100)) - : localize('chatInputWindow.sendingToNew', "Sending to {0}", current.label); - }; - renderLabel(); - - let remainingSeconds = Math.ceil(ROUTE_AUTOSEND_DELAY_MS / 1000); - const renderCountdown = () => { - countdownEl.textContent = localize('chatInputWindow.sendingIn', "sending in {0}s", remainingSeconds); - }; - - const send = () => { - // Detach the badge (and its listeners) before dispatch so a clear of - // the input during send can't re-enter cancel(). - this._pendingSend.clear(); - void this._dispatchTo(current, submittedInput, utterance, attachedContext, cts.token); - }; - - // Countdown lives in a MutableDisposable so it can be paused while the - // "Change" picker is open and restarted afterwards. - const countdownTimer = store.add(new MutableDisposable()); - const startCountdown = () => { - remainingSeconds = Math.ceil(ROUTE_AUTOSEND_DELAY_MS / 1000); - renderCountdown(); - const handle = targetWindow.setInterval(() => { - remainingSeconds--; - if (remainingSeconds <= 0) { - send(); - return; - } - renderCountdown(); - }, 1000); - countdownTimer.value = toDisposable(() => targetWindow.clearInterval(handle)); - }; - - const cancel = () => { - cts.cancel(); - this._pendingSend.clear(); - }; - store.add(dom.addDisposableListener(cancelEl, dom.EventType.CLICK, cancel)); - store.add(dom.addStandardDisposableListener(cancelEl, dom.EventType.KEY_DOWN, e => { - if (e.equals(KeyCode.Enter) || e.equals(KeyCode.Space)) { - e.preventDefault(); - cancel(); - } - })); - - const change = async () => { - countdownTimer.clear(); - const labelById = new Map(candidates.map(c => [c.sessionId, c.label])); - const preselected = current.kind === 'session' ? current.sessionId : 'new'; - const choice = await this._promptSessionChoice(results, labelById, preselected); - if (cts.token.isCancellationRequested || this._pendingSend.value !== store) { - return; - } - if (choice === undefined) { - startCountdown(); - return; - } - if (choice === 'new') { - current = { kind: 'new', label: localize('chatInputWindow.newSession', "New session") }; - } else { - const match = results.find(r => r.sessionId === choice); - current = { kind: 'session', sessionId: choice, label: labelById.get(choice) ?? choice, confidence: match?.confidence ?? 0 }; - } - renderLabel(); - startCountdown(); - }; - store.add(dom.addDisposableListener(changeEl, dom.EventType.CLICK, () => void change())); - store.add(dom.addStandardDisposableListener(changeEl, dom.EventType.KEY_DOWN, e => { - if (e.equals(KeyCode.Enter) || e.equals(KeyCode.Space)) { - e.preventDefault(); - void change(); - } - })); - - // Typing in the input cancels the auto-send so an edit never silently sends. - const editor = this._widget?.inputEditor; - if (editor) { - store.add(editor.onDidChangeModelContent(() => cancel())); - } - - this._pendingSend.value = store; - startCountdown(); - } - - /** Dispatch a resolved pending target, remembering it for next time. */ - private async _dispatchTo(target: PendingTarget, submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken): Promise { - if (target.kind === 'new') { - return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); - } - return this._dispatchToSession(target.sessionId, submittedInput, utterance, attachedContext, token); - } - - private async _dispatchToSession(sessionId: string, submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken): Promise { - let target: URI; - try { - target = URI.parse(sessionId); - } catch (err) { - this.logService.warn('[chatInputWindow] invalid session id for routing:', sessionId, err); - return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); - } - - try { - const ref = await this.chatService.acquireOrLoadSession(target, ChatAgentLocation.Chat, token, 'chatInputWindow-route'); - if (token.isCancellationRequested) { - ref?.dispose(); - return true; - } - if (!ref) { - this.logService.warn('[chatInputWindow] could not load routed session, starting a new one:', sessionId); - return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); - } - this._retainSessionRef(target, ref); - const result = await this.chatService.sendRequest(target, utterance, attachedContext?.length ? { attachedContext } : undefined); - if (token.isCancellationRequested) { - return true; - } - if (!result || result.kind === 'rejected') { - this.logService.warn('[chatInputWindow] routed session rejected the request, starting a new one:', sessionId); - return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); - } - // Remember this session so the next request biases toward it. - this.storageService.store(ChatInputWindowStorageKeys.LastTarget, sessionId, StorageScope.WORKSPACE, StorageTarget.MACHINE); - this._clearInputIfUnchanged(submittedInput); - return true; - } catch (err) { - if (token.isCancellationRequested) { - return true; - } - this.logService.warn('[chatInputWindow] error dispatching to routed session, starting a new one:', err); - return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); - } - } - - private async _dispatchToNewSession(submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken): Promise { - try { - const ref = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { debugOwner: 'chatInputWindow-new' }); - if (token.isCancellationRequested) { - ref.dispose(); - return true; - } - this._retainSessionRef(ref.object.sessionResource, ref); - const result = await this.chatService.sendRequest(ref.object.sessionResource, utterance, attachedContext?.length ? { attachedContext } : undefined); - if (token.isCancellationRequested) { - return true; - } - if (!result || result.kind === 'rejected') { - this.logService.warn('[chatInputWindow] new session rejected the request, running locally'); - return false; - } - this._clearInputIfUnchanged(submittedInput); - return true; - } catch (err) { - if (token.isCancellationRequested) { - return true; - } - this.logService.warn('[chatInputWindow] error starting a new session, running locally:', err); - return false; - } - } - - /** - * Retain at most one reference per session resource so a long-lived window - * doesn't accumulate model references (and their sessions) as more requests - * are routed to the same target. - */ - private _retainSessionRef(resource: URI, ref: IChatModelReference): void { - if (this._routedSessionRefs.has(resource)) { - ref.dispose(); - return; - } - this._routedSessionRefs.set(resource, ref); - } - - /** - * Clear the input (and its explicit attachments) only if the editor still - * holds exactly what was submitted, so a newer draft typed while the request - * was in flight is preserved. - */ - private _clearInputIfUnchanged(submittedInput: string): void { - const editor = this._widget?.inputEditor; - if (editor && editor.getValue() === submittedInput) { - editor.setValue(''); - this._widget?.attachmentModel.clear(); - } - } - private _disposeWidget(): void { - this._pendingSend.clear(); - this._widget = undefined; + this._routingController = undefined; this._widgetParent = undefined; this._modelRef?.dispose(); this._modelRef = undefined; - for (const ref of this._routedSessionRefs.values()) { - ref.dispose(); - } - this._routedSessionRefs.clear(); } private _defaultBounds(): IRectangle { @@ -722,11 +309,3 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind registerSingleton(IChatInputWindowService, ChatInputWindowService, InstantiationType.Delayed); -function statusToString(status: AgentSessionStatus): string { - switch (status) { - case AgentSessionStatus.Failed: return 'failed'; - case AgentSessionStatus.Completed: return 'idle'; - case AgentSessionStatus.InProgress: return 'working'; - default: return 'unknown'; - } -} diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts new file mode 100644 index 00000000000000..09fb77d36a0f3b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts @@ -0,0 +1,583 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../../base/common/map.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; +import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; +import { IChatModelReference, IChatService } from '../../common/chatService/chatService.js'; +import { IRoutableSession, ISessionRouteResult, ISessionRouter } from '../../common/sessionRouter.js'; +import { IAgentSession, AgentSessionStatus } from '../agentSessions/agentSessionsModel.js'; +import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; +import { IChatWidgetService } from '../chat.js'; +import { ChatWidget } from '../widget/chatWidget.js'; + +import './media/chatSessionRouting.css'; + +/** + * Minimum confidence for a candidate to be treated as a real match. Below this + * for every candidate, the request targets a brand-new session instead. + */ +const ROUTE_CONFIDENCE_THRESHOLD = 0.5; + +/** + * When the last-used session is within this confidence margin of the top match, + * it is preferred so repeated turns keep landing on the same session. + */ +const ROUTE_AMBIGUITY_MARGIN = 0.2; + +/** Maximum number of options shown in the disambiguation picker. */ +const ROUTE_MAX_CHOICES = 6; + +/** + * How long the pending-send badge counts down before auto-dispatching to the + * routed target. Long enough to read the target and intervene, short enough to + * keep a hands-free/voice flow moving. + */ +const ROUTE_AUTOSEND_DELAY_MS = 3000; + +/** Workspace-scoped memory of the last routed session, biasing the next turn. */ +const LAST_TARGET_STORAGE_KEY = 'chat.sessionRouting.lastTarget'; + +/** Resolved destination for a submitted request: an existing session or a new one. */ +type PendingTarget = + | { readonly kind: 'session'; readonly sessionId: string; readonly label: string; readonly confidence: number } + | { readonly kind: 'new'; readonly label: string }; + +function statusToString(status: AgentSessionStatus): string { + switch (status) { + case AgentSessionStatus.Failed: return 'failed'; + case AgentSessionStatus.Completed: return 'idle'; + case AgentSessionStatus.InProgress: return 'working'; + default: return 'unknown'; + } +} + +/** + * The surface (floating input window, quick chat, …) that hosts a routed chat + * input. Supplies the widget being routed, its own scratch session to exclude + * from candidates, and where the advisory badge should be inserted. + */ +export interface IChatSessionRoutingHost { + /** The chat widget whose submission is being routed. */ + readonly widget: ChatWidget; + /** Resource of the host's own scratch session, excluded from routing candidates. */ + getOwnSessionResource(): URI | undefined; + /** + * Insert the advisory badge into the host DOM, positioned above the input. + * If the host has no surface to place it, leave the badge disconnected and + * the controller will fall back to an immediate dispatch. + */ + placeBadge(badge: HTMLElement): void; +} + +/** + * Shared routing + advisory-badge behaviour for chat input surfaces. Scores a + * submitted utterance against existing agent sessions, resolves a single pending + * target (best match above threshold, else a new session), then shows a badge + * that counts down and auto-sends. Routing is never silent: the user can + * redirect ("Change"), abort ("Cancel"), or keep typing to cancel the auto-send + * before it fires. The last routed session is remembered to bias the next turn. + */ +export class ChatSessionRoutingController extends Disposable { + + /** Active pending-send badge + auto-send timers; replaced/cleared per submission. */ + private readonly _pendingSend = this._register(new MutableDisposable()); + /** Sessions loaded or spawned by routing, deduped by resource; disposed on teardown. */ + private readonly _routedSessionRefs = new ResourceMap(); + /** Cancellation for the in-flight submission; canceled when the host tears down. */ + private readonly _submitCts = this._register(new MutableDisposable()); + + constructor( + private readonly host: IChatSessionRoutingHost, + private readonly debugOwner: string, + @IChatService private readonly chatService: IChatService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ISessionRouter private readonly sessionRouter: ISessionRouter, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IStorageService private readonly storageService: IStorageService, + @ILogService private readonly logService: ILogService, + ) { + super(); + } + + /** + * Intercept a submission before local execution: score it against existing + * sessions, resolve a pending target, and show the advisory badge. Always + * returns `true` (handled) so the input-only widget never runs the request on + * its own scratch session. + */ + async handleSubmit(query: string, _mode: ChatModeKind, attachedContext?: IChatRequestVariableEntry[]): Promise { + const utterance = query.trim(); + if (!utterance) { + return false; + } + + // A new submission supersedes any pending badge from a previous one. + this._pendingSend.clear(); + + // Replacing the source disposes any previous one; the host cancels the + // in-flight submission on teardown so we never dispatch after close. + const cts = new CancellationTokenSource(); + this._submitCts.value = cts; + const token = cts.token; + + const candidates = await this._collectCandidateSessions(token); + if (token.isCancellationRequested) { + return true; + } + + const results = candidates.length ? await this._route(candidates, utterance, token) : []; + if (token.isCancellationRequested) { + return true; + } + + const target = this._resolveTarget(results, candidates); + this._beginPendingSend(target, results, candidates, query, utterance, attachedContext, cts); + return true; + } + + /** Cancel any in-flight submission and remove the pending badge. */ + cancelPending(): void { + this._submitCts.value?.cancel(); + this._submitCts.clear(); + this._pendingSend.clear(); + } + + /** Run the router, degrading to an empty ranking on failure/cancellation. */ + private async _route(candidates: IRoutableSession[], utterance: string, token: CancellationToken): Promise { + try { + return await this.sessionRouter.route({ utterance, sessions: candidates }, token); + } catch (err) { + if (!token.isCancellationRequested) { + this.logService.warn('[chatSessionRouting] session routing failed:', err); + } + return []; + } + } + + /** + * Pick the single pending target the badge pre-selects: the top match if it + * clears the confidence threshold (biased toward the last-used session on a + * tie within the ambiguity margin), otherwise a brand-new session. + */ + private _resolveTarget(results: ISessionRouteResult[], candidates: IRoutableSession[]): PendingTarget { + const labelById = new Map(candidates.map(c => [c.sessionId, c.label])); + const top = results[0]; + if (!top || top.confidence < ROUTE_CONFIDENCE_THRESHOLD) { + return { kind: 'new', label: localize('chatSessionRouting.newSession', "New session") }; + } + + // Prefer the last-used session when it is within the ambiguity margin of + // the top match, so repeated turns keep landing on the same session. + const lastTargetId = this.storageService.get(LAST_TARGET_STORAGE_KEY, StorageScope.WORKSPACE); + const preferred = lastTargetId + ? results.find(r => r.sessionId === lastTargetId + && r.confidence >= ROUTE_CONFIDENCE_THRESHOLD + && (top.confidence - r.confidence) <= ROUTE_AMBIGUITY_MARGIN) + : undefined; + const chosen = preferred ?? top; + return { + kind: 'session', + sessionId: chosen.sessionId, + label: labelById.get(chosen.sessionId) ?? chosen.sessionId, + confidence: chosen.confidence, + }; + } + + /** + * Snapshot the current agent sessions as routing candidates, excluding the + * host's own scratch session so it can never route to itself. Awaits the + * session model so a pending first-load/refresh isn't missed. + */ + private async _collectCandidateSessions(token: CancellationToken): Promise { + try { + await this.agentSessionsService.model.resolve(undefined); + } catch (err) { + this.logService.warn('[chatSessionRouting] resolving agent sessions failed:', err); + } + if (token.isCancellationRequested) { + return []; + } + const ownResource = this.host.getOwnSessionResource()?.toString(); + return this.agentSessionsService.model.sessions + .filter(session => session.resource.toString() !== ownResource) + .map(session => this._toRoutableSession(session)); + } + + private _toRoutableSession(session: IAgentSession): IRoutableSession { + return { + sessionId: session.resource.toString(), + label: session.label, + status: statusToString(session.status), + lastActivity: session.timing?.lastRequestEnded ?? session.timing?.lastRequestStarted ?? session.timing?.created, + }; + } + + /** + * Ask the user to pick a target, listing the scored sessions (best first, + * capped) plus a new-session option. `preselectedId` is floated to the top so + * it is the default highlighted choice. Returns the chosen session id, + * `'new'` for a new session, or `undefined` if the picker was dismissed. + */ + private async _promptSessionChoice(results: ISessionRouteResult[], labelById: Map, preselectedId?: string): Promise { + type RouteChoiceItem = IQuickPickItem & { sessionId?: string; isNew?: boolean }; + const ordered = results.slice(0, ROUTE_MAX_CHOICES); + if (preselectedId && preselectedId !== 'new') { + const idx = ordered.findIndex(r => r.sessionId === preselectedId); + if (idx > 0) { + ordered.unshift(ordered.splice(idx, 1)[0]); + } + } + const items: RouteChoiceItem[] = ordered.map(match => ({ + label: labelById.get(match.sessionId) ?? match.sessionId, + description: localize('chatSessionRouting.matchPercent', "{0}% match", Math.round(match.confidence * 100)), + detail: match.reason, + sessionId: match.sessionId, + })); + const newItem: RouteChoiceItem = { + label: `$(add) ${localize('chatSessionRouting.newSession', "New session")}`, + isNew: true, + }; + if (preselectedId === 'new') { + items.unshift(newItem); + } else { + items.push(newItem); + } + + const picked = await this.quickInputService.pick(items, { + placeHolder: localize('chatSessionRouting.choosePlaceholder', "Choose where to send this request"), + }); + if (!picked) { + return undefined; + } + return picked.isNew ? 'new' : picked.sessionId; + } + + /** + * Show the advisory pending-send badge for a resolved target. A confident + * session match counts down and auto-sends (redirectable/cancelable); a + * no-match creates and sends to a new chat immediately and links to it. + */ + private _beginPendingSend( + target: PendingTarget, + results: ISessionRouteResult[], + candidates: IRoutableSession[], + submittedInput: string, + utterance: string, + attachedContext: IChatRequestVariableEntry[] | undefined, + cts: CancellationTokenSource, + ): void { + const badge = dom.$('.chat-routing-badge'); + this.host.placeBadge(badge); + if (!badge.parentElement) { + // No surface to host the badge — fall back to an immediate dispatch. + void this._dispatchTo(target, submittedInput, utterance, attachedContext, cts.token); + return; + } + + const store = new DisposableStore(); + store.add(toDisposable(() => badge.remove())); + this._pendingSend.value = store; + + if (target.kind === 'new') { + // No confident match: don't delay — create and send to a new chat right + // away, then surface a link to it in the badge as soon as it exists. + this._renderNewSessionBadge(badge, store, submittedInput, utterance, attachedContext, cts); + } else { + this._renderCountdownBadge(badge, store, target, results, candidates, submittedInput, utterance, attachedContext, cts); + } + } + + /** + * Confident-match badge: names the routed session and counts down, then + * auto-sends. The user can redirect ("Change"), abort ("Cancel"), or keep + * typing (which cancels the auto-send) before it fires. Choosing "New + * session" in the picker hands off to the immediate new-session flow. + */ + private _renderCountdownBadge( + badge: HTMLElement, + store: DisposableStore, + target: PendingTarget, + results: ISessionRouteResult[], + candidates: IRoutableSession[], + submittedInput: string, + utterance: string, + attachedContext: IChatRequestVariableEntry[] | undefined, + cts: CancellationTokenSource, + ): void { + const targetWindow = dom.getWindow(badge); + let current = target; + + const label = dom.append(badge, dom.$('span.chat-routing-badge-label')); + const countdownEl = dom.append(badge, dom.$('span.chat-routing-badge-countdown')); + + const renderLabel = () => { + label.textContent = current.kind === 'session' + ? localize('chatSessionRouting.sendingToSession', "Sending to {0} · {1}% match", current.label, Math.round(current.confidence * 100)) + : localize('chatSessionRouting.sendingToNew', "Sending to {0}", current.label); + }; + renderLabel(); + + let remainingSeconds = Math.ceil(ROUTE_AUTOSEND_DELAY_MS / 1000); + const renderCountdown = () => { + countdownEl.textContent = localize('chatSessionRouting.sendingIn', "sending in {0}s", remainingSeconds); + }; + + const send = () => { + // Detach the badge (and its listeners) before dispatch so a clear of + // the input during send can't re-enter cancel(). + this._pendingSend.clear(); + void this._dispatchTo(current, submittedInput, utterance, attachedContext, cts.token); + }; + + // Countdown lives in a MutableDisposable so it can be paused while the + // "Change" picker is open and restarted afterwards. + const countdownTimer = store.add(new MutableDisposable()); + const startCountdown = () => { + remainingSeconds = Math.ceil(ROUTE_AUTOSEND_DELAY_MS / 1000); + renderCountdown(); + const handle = targetWindow.setInterval(() => { + remainingSeconds--; + if (remainingSeconds <= 0) { + send(); + return; + } + renderCountdown(); + }, 1000); + countdownTimer.value = toDisposable(() => targetWindow.clearInterval(handle)); + }; + + const cancel = () => { + cts.cancel(); + this._pendingSend.clear(); + }; + + const change = async () => { + countdownTimer.clear(); + const labelById = new Map(candidates.map(c => [c.sessionId, c.label])); + const preselected = current.kind === 'session' ? current.sessionId : 'new'; + const choice = await this._promptSessionChoice(results, labelById, preselected); + if (cts.token.isCancellationRequested || this._pendingSend.value !== store) { + return; + } + if (choice === undefined) { + startCountdown(); + return; + } + if (choice === 'new') { + // Redirecting to a new session follows the same no-delay path. + dom.clearNode(badge); + this._renderNewSessionBadge(badge, store, submittedInput, utterance, attachedContext, cts); + return; + } + const match = results.find(r => r.sessionId === choice); + current = { kind: 'session', sessionId: choice, label: labelById.get(choice) ?? choice, confidence: match?.confidence ?? 0 }; + renderLabel(); + startCountdown(); + }; + + this._addActionLink(store, badge, localize('chatSessionRouting.change', "Change"), () => void change()); + this._addActionLink(store, badge, localize('chatSessionRouting.cancel', "Cancel"), cancel); + + // Typing in the input cancels the auto-send so an edit never silently sends. + store.add(this.host.widget.inputEditor.onDidChangeModelContent(() => cancel())); + + startCountdown(); + } + + /** + * No-match badge: creates the new chat immediately (no countdown), fires the + * request, and — since the session resource exists right away — shows a link + * that opens the newly created chat. + */ + private _renderNewSessionBadge( + badge: HTMLElement, + store: DisposableStore, + submittedInput: string, + utterance: string, + attachedContext: IChatRequestVariableEntry[] | undefined, + cts: CancellationTokenSource, + ): void { + const label = dom.append(badge, dom.$('span.chat-routing-badge-label')); + + let resource: URI | undefined; + try { + const ref = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { debugOwner: `${this.debugOwner}-new` }); + this._retainSessionRef(ref.object.sessionResource, ref); + resource = ref.object.sessionResource; + } catch (err) { + this.logService.warn('[chatSessionRouting] error starting a new session:', err); + } + + if (!resource) { + label.textContent = localize('chatSessionRouting.noMatchFailed', "No matching chat found — could not create a new chat"); + this._addActionLink(store, badge, localize('chatSessionRouting.dismiss', "Dismiss"), () => this._pendingSend.clear()); + return; + } + const sessionResource = resource; + + label.textContent = localize('chatSessionRouting.noMatch', "No matching chat found — sent to a new chat"); + this._addActionLink(store, badge, localize('chatSessionRouting.openNewChat', "Open new chat"), () => { + void this.chatWidgetService.openSession(sessionResource); + }); + this._addActionLink(store, badge, localize('chatSessionRouting.dismiss', "Dismiss"), () => this._pendingSend.clear()); + + // Fire the request; the badge stays so the link remains usable. + void this._sendToNewSession(sessionResource, submittedInput, utterance, attachedContext, cts.token); + } + + /** Append an accessible link-style action to the badge. */ + private _addActionLink(store: DisposableStore, badge: HTMLElement, text: string, run: () => void): void { + const el = dom.append(badge, dom.$('a.chat-routing-badge-action', { role: 'button', tabindex: '0' })); + el.textContent = text; + store.add(dom.addDisposableListener(el, dom.EventType.CLICK, run)); + store.add(dom.addStandardDisposableListener(el, dom.EventType.KEY_DOWN, e => { + if (e.equals(KeyCode.Enter) || e.equals(KeyCode.Space)) { + e.preventDefault(); + run(); + } + })); + } + + /** Dispatch a resolved pending target, remembering it for next time. */ + private async _dispatchTo(target: PendingTarget, submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken): Promise { + if (target.kind === 'new') { + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); + } + return this._dispatchToSession(target.sessionId, submittedInput, utterance, attachedContext, token); + } + + /** Send to an already-created new session (used by the no-delay no-match flow). */ + private async _sendToNewSession(resource: URI, submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken): Promise { + try { + const result = await this.chatService.sendRequest(resource, utterance, attachedContext?.length ? { attachedContext } : undefined); + if (token.isCancellationRequested) { + return; + } + if (!result || result.kind === 'rejected') { + this.logService.warn('[chatSessionRouting] new session rejected the request'); + return; + } + this._clearInputIfUnchanged(submittedInput); + } catch (err) { + if (!token.isCancellationRequested) { + this.logService.warn('[chatSessionRouting] error sending to new session:', err); + } + } + } + + private async _dispatchToSession(sessionId: string, submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken): Promise { + let target: URI; + try { + target = URI.parse(sessionId); + } catch (err) { + this.logService.warn('[chatSessionRouting] invalid session id for routing:', sessionId, err); + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); + } + + try { + const ref = await this.chatService.acquireOrLoadSession(target, ChatAgentLocation.Chat, token, `${this.debugOwner}-route`); + if (token.isCancellationRequested) { + ref?.dispose(); + return true; + } + if (!ref) { + this.logService.warn('[chatSessionRouting] could not load routed session, starting a new one:', sessionId); + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); + } + this._retainSessionRef(target, ref); + const result = await this.chatService.sendRequest(target, utterance, attachedContext?.length ? { attachedContext } : undefined); + if (token.isCancellationRequested) { + return true; + } + if (!result || result.kind === 'rejected') { + this.logService.warn('[chatSessionRouting] routed session rejected the request, starting a new one:', sessionId); + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); + } + // Remember this session so the next request biases toward it. + this.storageService.store(LAST_TARGET_STORAGE_KEY, sessionId, StorageScope.WORKSPACE, StorageTarget.MACHINE); + this._clearInputIfUnchanged(submittedInput); + return true; + } catch (err) { + if (token.isCancellationRequested) { + return true; + } + this.logService.warn('[chatSessionRouting] error dispatching to routed session, starting a new one:', err); + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); + } + } + + private async _dispatchToNewSession(submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken): Promise { + try { + const ref = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { debugOwner: `${this.debugOwner}-new` }); + if (token.isCancellationRequested) { + ref.dispose(); + return true; + } + this._retainSessionRef(ref.object.sessionResource, ref); + const result = await this.chatService.sendRequest(ref.object.sessionResource, utterance, attachedContext?.length ? { attachedContext } : undefined); + if (token.isCancellationRequested) { + return true; + } + if (!result || result.kind === 'rejected') { + this.logService.warn('[chatSessionRouting] new session rejected the request, running locally'); + return false; + } + this._clearInputIfUnchanged(submittedInput); + return true; + } catch (err) { + if (token.isCancellationRequested) { + return true; + } + this.logService.warn('[chatSessionRouting] error starting a new session, running locally:', err); + return false; + } + } + + /** + * Retain at most one reference per session resource so a long-lived host + * doesn't accumulate model references (and their sessions) as more requests + * are routed to the same target. + */ + private _retainSessionRef(resource: URI, ref: IChatModelReference): void { + if (this._routedSessionRefs.has(resource)) { + ref.dispose(); + return; + } + this._routedSessionRefs.set(resource, ref); + } + + /** + * Clear the input (and its explicit attachments) only if the editor still + * holds exactly what was submitted, so a newer draft typed while the request + * was in flight is preserved. + */ + private _clearInputIfUnchanged(submittedInput: string): void { + const editor = this.host.widget.inputEditor; + if (editor.getValue() === submittedInput) { + editor.setValue(''); + this.host.widget.attachmentModel.clear(); + } + } + + override dispose(): void { + this._pendingSend.clear(); + for (const ref of this._routedSessionRefs.values()) { + ref.dispose(); + } + this._routedSessionRefs.clear(); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css b/src/vs/workbench/contrib/chat/browser/sessionRouter/media/chatSessionRouting.css similarity index 77% rename from src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css rename to src/vs/workbench/contrib/chat/browser/sessionRouter/media/chatSessionRouting.css index d4b7e86357061c..da0465492d7815 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/media/chatSessionRouting.css @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.chat-input-window-pending { +.chat-routing-badge { display: flex; align-items: center; gap: 8px; @@ -18,7 +18,7 @@ border-bottom: 1px solid var(--vscode-editorWidget-border, transparent); } -.chat-input-window-pending .chat-input-window-pending-label { +.chat-routing-badge .chat-routing-badge-label { flex: 1 1 auto; min-width: 0; white-space: nowrap; @@ -26,12 +26,12 @@ text-overflow: ellipsis; } -.chat-input-window-pending .chat-input-window-pending-countdown { +.chat-routing-badge .chat-routing-badge-countdown { color: var(--vscode-descriptionForeground); font-variant-numeric: tabular-nums; } -.chat-input-window-pending .chat-input-window-pending-action { +.chat-routing-badge .chat-routing-badge-action { flex-shrink: 0; cursor: pointer; color: var(--vscode-textLink-foreground); @@ -39,12 +39,12 @@ -webkit-app-region: no-drag; } -.chat-input-window-pending .chat-input-window-pending-action:hover { +.chat-routing-badge .chat-routing-badge-action:hover { color: var(--vscode-textLink-activeForeground); text-decoration: underline; } -.chat-input-window-pending .chat-input-window-pending-action:focus-visible { +.chat-routing-badge .chat-routing-badge-action:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 2px; border-radius: var(--vscode-cornerRadius-small, 2px); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/chatQuick.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/chatQuick.ts index 9e5c9df96f38e4..93cc8376da2e06 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/chatQuick.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/chatQuick.ts @@ -15,6 +15,7 @@ import { Selection } from '../../../../../editor/common/core/selection.js'; import { localize } from '../../../../../nls.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; @@ -31,6 +32,8 @@ import { IChatModelReference, IChatProgress, IChatService } from '../../common/c import { ChatAgentLocation } from '../../common/constants.js'; import { IChatWidgetService, IQuickChatOpenOptions, IQuickChatService } from '../chat.js'; import { ChatWidget } from '../widget/chatWidget.js'; +import { ChatSessionRoutingController, IChatSessionRoutingHost } from '../sessionRouter/chatSessionRoutingController.js'; +import { OmniChatEnabledSettingId } from '../../common/sessionRouter.js'; export class QuickChatService extends Disposable implements IQuickChatService { readonly _serviceBrand: undefined; @@ -155,6 +158,8 @@ class QuickChat extends Disposable { private widget!: ChatWidget; private sash!: Sash; private modelRef: IChatModelReference | undefined; + /** Omni routing (advisory badge); created lazily in render, gated by `chat.omni.enabled` at submit. */ + private routingController: ChatSessionRoutingController | undefined; private readonly maintainScrollTimer: MutableDisposable = this._register(new MutableDisposable()); private _deferUpdatingDynamicLayout: boolean = false; @@ -170,6 +175,7 @@ class QuickChat extends Disposable { @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); } @@ -199,6 +205,9 @@ class QuickChat extends Disposable { hide(): void { this.widget.setVisible(false); + // Drop any pending advisory badge so a routed request can't auto-send + // after the quick chat is dismissed. + this.routingController?.cancelPending(); // Maintain scroll position for a short time so that if the user re-shows the chat // the same scroll position will be used. this.maintainScrollTimer.value = disposableTimeout(() => { @@ -245,6 +254,14 @@ class QuickChat extends Disposable { enableImplicitContext: true, defaultMode: ChatMode.Ask, clear: () => this.clear(), + // Omni routing: when `chat.omni.enabled`, route the submission via + // the advisory badge instead of running it on the local session. + submitHandler: (query, mode, attachedContext) => { + if (!this.configurationService.getValue(OmniChatEnabledSettingId)) { + return Promise.resolve(false); + } + return this.routingController?.handleSubmit(query, mode, attachedContext) ?? Promise.resolve(false); + }, }, { listForeground: quickInputForeground, @@ -255,6 +272,16 @@ class QuickChat extends Disposable { })); this.widget.render(parent); this.widget.setVisible(true); + + // Route submissions through the shared controller, inserting its advisory + // badge at the top of the quick chat, above the input. + const routingHost: IChatSessionRoutingHost = { + widget: this.widget, + getOwnSessionResource: () => this.modelRef?.object.sessionResource, + placeBadge: (badge) => parent.insertBefore(badge, parent.firstChild), + }; + this.routingController = this._register(this.instantiationService.createInstance(ChatSessionRoutingController, routingHost, 'chatQuick')); + this.widget.setDynamicChatTreeItemLayout(2, this.maxHeight); this.updateModel(); this.sash = this._register(new Sash(parent, { getHorizontalSashTop: () => parent.offsetHeight }, { orientation: Orientation.HORIZONTAL })); diff --git a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts index e089be12ebf09c..bf41d16ff46a17 100644 --- a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts +++ b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts @@ -17,7 +17,6 @@ export const CHAT_INPUT_WINDOW_DEFAULT_HEIGHT = 110; */ export const enum ChatInputWindowStorageKeys { WindowOpen = 'chatInputWindow.windowOpen', - LastTarget = 'chatInputWindow.lastTarget', } export const IChatInputWindowService = createDecorator('chatInputWindowService'); diff --git a/src/vs/workbench/contrib/chat/common/sessionRouter.ts b/src/vs/workbench/contrib/chat/common/sessionRouter.ts index 81d8870fac5252..72a8f2f28fc187 100644 --- a/src/vs/workbench/contrib/chat/common/sessionRouter.ts +++ b/src/vs/workbench/contrib/chat/common/sessionRouter.ts @@ -6,6 +6,12 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +/** + * Setting that gates the "omni" chat experience — advisory badge routing on omni + * surfaces such as Quick Chat. See `chat.shared.contribution.ts` for the schema. + */ +export const OmniChatEnabledSettingId = 'chat.omni.enabled'; + /** * A session that a user request can be routed to. Populated by the caller from * the session list (e.g. `IChatSessionsService` / `ISessionsService`). From fd40aead1e5a6e9303363ec0547137040b11f9be Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 22 Jul 2026 16:57:53 -0400 Subject: [PATCH 08/11] =?UTF-8?q?Add=20"Sent=20to=20=E2=80=A6"=20confirmat?= =?UTF-8?q?ion=20badge=20after=20a=20matched=20omni=20send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the router auto-sends to a matched session, show a brief inline confirmation badge ("Sent to «session»") with an Open link, so omni surfaces that don't render the response inline (Quick Chat, aux input window) still confirm where the request went. Auto-dismisses after 4s. Guarded on the current submission so a newer submit isn't overwritten. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ece96f2d-c878-4a57-97c5-884542b8581e --- .../chatSessionRoutingController.ts | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts index 09fb77d36a0f3b..dbd6dbebe5b82e 100644 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts @@ -46,6 +46,13 @@ const ROUTE_MAX_CHOICES = 6; */ const ROUTE_AUTOSEND_DELAY_MS = 3000; +/** + * How long the "Sent to …" confirmation badge lingers after a matched send + * before auto-dismissing. Long enough to register where the request went, short + * enough not to get in the way of firing the next one. + */ +const SENT_CONFIRMATION_MS = 4000; + /** Workspace-scoped memory of the last routed session, biasing the next turn. */ const LAST_TARGET_STORAGE_KEY = 'chat.sessionRouting.lastTarget'; @@ -339,7 +346,15 @@ export class ChatSessionRoutingController extends Disposable { // Detach the badge (and its listeners) before dispatch so a clear of // the input during send can't re-enter cancel(). this._pendingSend.clear(); - void this._dispatchTo(current, submittedInput, utterance, attachedContext, cts.token); + const sent = current; + void this._dispatchTo(sent, submittedInput, utterance, attachedContext, cts.token).then(ok => { + // Confirm where the request went so an omni surface that can't show + // the response inline still gives feedback. Guard on the current + // submission so a newer one isn't overwritten. + if (ok && sent.kind === 'session' && this._submitCts.value === cts) { + this._showSentConfirmation(sent.label, sent.sessionId); + } + }); }; // Countdown lives in a MutableDisposable so it can be paused while the @@ -438,6 +453,43 @@ export class ChatSessionRoutingController extends Disposable { void this._sendToNewSession(sessionResource, submittedInput, utterance, attachedContext, cts.token); } + /** + * Show a brief "Sent to …" confirmation after a matched send, so an omni + * surface that can't render the response inline still confirms where the + * request went. Offers an "Open" link and auto-dismisses. + */ + private _showSentConfirmation(label: string, sessionId: string): void { + let resource: URI; + try { + resource = URI.parse(sessionId); + } catch { + return; + } + + const badge = dom.$('.chat-routing-badge'); + const labelEl = dom.append(badge, dom.$('span.chat-routing-badge-label')); + labelEl.textContent = localize('chatSessionRouting.sentTo', "Sent to {0}", label); + this.host.placeBadge(badge); + if (!badge.parentElement) { + return; + } + + const store = new DisposableStore(); + store.add(toDisposable(() => badge.remove())); + this._addActionLink(store, badge, localize('chatSessionRouting.open', "Open"), () => void this.chatWidgetService.openSession(resource)); + this._addActionLink(store, badge, localize('chatSessionRouting.dismiss', "Dismiss"), () => this._pendingSend.clear()); + + const targetWindow = dom.getWindow(badge); + const handle = targetWindow.setTimeout(() => { + if (this._pendingSend.value === store) { + this._pendingSend.clear(); + } + }, SENT_CONFIRMATION_MS); + store.add(toDisposable(() => targetWindow.clearTimeout(handle))); + + this._pendingSend.value = store; + } + /** Append an accessible link-style action to the badge. */ private _addActionLink(store: DisposableStore, badge: HTMLElement, text: string, run: () => void): void { const el = dom.append(badge, dom.$('a.chat-routing-badge-action', { role: 'button', tabindex: '0' })); From 152f58c8f917277040e75695364ca4118e7edc98 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 22 Jul 2026 17:08:27 -0400 Subject: [PATCH 09/11] Lengthen routing auto-send countdown to 8s Give more time to redirect or cancel before the matched send fires. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ece96f2d-c878-4a57-97c5-884542b8581e --- .../chat/browser/sessionRouter/chatSessionRoutingController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts index dbd6dbebe5b82e..a169bbbffc5b9a 100644 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts @@ -44,7 +44,7 @@ const ROUTE_MAX_CHOICES = 6; * routed target. Long enough to read the target and intervene, short enough to * keep a hands-free/voice flow moving. */ -const ROUTE_AUTOSEND_DELAY_MS = 3000; +const ROUTE_AUTOSEND_DELAY_MS = 8000; /** * How long the "Sent to …" confirmation badge lingers after a matched send From 0e5962d075d8427ee6ebe569d4e622447dccb9e1 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 22 Jul 2026 17:08:49 -0400 Subject: [PATCH 10/11] Set routing auto-send countdown to 10s Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ece96f2d-c878-4a57-97c5-884542b8581e --- .../chat/browser/sessionRouter/chatSessionRoutingController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts index a169bbbffc5b9a..13c9e5fdec4660 100644 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts @@ -44,7 +44,7 @@ const ROUTE_MAX_CHOICES = 6; * routed target. Long enough to read the target and intervene, short enough to * keep a hands-free/voice flow moving. */ -const ROUTE_AUTOSEND_DELAY_MS = 8000; +const ROUTE_AUTOSEND_DELAY_MS = 10000; /** * How long the "Sent to …" confirmation badge lingers after a matched send From e05b33318a7c6dd5ff535b2c945cbc9a435dfab4 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 22 Jul 2026 17:12:17 -0400 Subject: [PATCH 11/11] Grow aux input window to fit the routing picker The disambiguation picker renders into the focused aux window's container; since that frameless window is only tall enough for the input, the options were clipped. Resize the window to fit the rows while the picker is open (via an onPickerVisibility host hook on the shared routing controller) and restore the height on close. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ece96f2d-c878-4a57-97c5-884542b8581e --- .../chatInputWindow/chatInputWindowService.ts | 34 +++++++++++++++++++ .../chatSessionRoutingController.ts | 19 +++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index f1bff499ca7134..efcc324abf3dc7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -28,6 +28,13 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatSessionRoutingController, IChatSessionRoutingHost } from '../sessionRouter/chatSessionRoutingController.js'; import { IChatInputWindowService, ChatInputWindowStorageKeys, CHAT_INPUT_WINDOW_DEFAULT_WIDTH, CHAT_INPUT_WINDOW_DEFAULT_HEIGHT } from '../../common/chatInputWindow.js'; +/** Approx. rendered height of one quick-pick row, used to size the window to fit the routing picker. */ +const CHAT_INPUT_WINDOW_PICKER_ROW_HEIGHT = 22; +/** Max picker rows to grow the window for; taller lists scroll instead. */ +const CHAT_INPUT_WINDOW_PICKER_MAX_ROWS = 8; +/** Extra height for the picker's filter input and padding on top of the rows. */ +const CHAT_INPUT_WINDOW_PICKER_CHROME = 60; + /** * Hosts a frameless, always-on-top auxiliary window containing the full chat * input box — dictation, voice mode, and the glow animation. Submissions are @@ -52,6 +59,8 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private _routingController: ChatSessionRoutingController | undefined; /** In-flight `openWindow()` operation, so concurrent toggles stay idempotent. */ private _openOperation: Promise | undefined; + /** Window height (outer) captured before growing to fit the routing picker; restored on close. */ + private _preExpandHeight: number | undefined; get isOpen(): boolean { return !!this._window; @@ -278,6 +287,30 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind container.insertBefore(badge, this._widgetParent); } }, + // The frameless window is only tall enough for the input, so the + // disambiguation picker (rendered into this window's container) would + // be clipped. Grow to fit the rows while it's open, restore on close. + onPickerVisibility: (visible, itemCount) => { + const win = this._window?.window; + if (!win) { + return; + } + try { + if (visible) { + if (this._preExpandHeight === undefined) { + this._preExpandHeight = win.outerHeight; + } + const rows = Math.min(Math.max(itemCount, 1), CHAT_INPUT_WINDOW_PICKER_MAX_ROWS); + const desired = CHAT_INPUT_WINDOW_DEFAULT_HEIGHT + rows * CHAT_INPUT_WINDOW_PICKER_ROW_HEIGHT + CHAT_INPUT_WINDOW_PICKER_CHROME; + const screenBottom = win.screen.availHeight; + const maxHeight = Math.max(screenBottom - win.screenY, this._preExpandHeight); + win.resizeTo(win.outerWidth, Math.min(desired, maxHeight)); + } else if (this._preExpandHeight !== undefined) { + win.resizeTo(win.outerWidth, this._preExpandHeight); + this._preExpandHeight = undefined; + } + } catch { /* resize may not be supported */ } + }, }; this._routingController = this._windowDisposables.add(this.instantiationService.createInstance(ChatSessionRoutingController, host, 'chatInputWindow')); @@ -290,6 +323,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private _disposeWidget(): void { this._routingController = undefined; this._widgetParent = undefined; + this._preExpandHeight = undefined; this._modelRef?.dispose(); this._modelRef = undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts index 13c9e5fdec4660..c1843a0c01843f 100644 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts @@ -86,6 +86,13 @@ export interface IChatSessionRoutingHost { * the controller will fall back to an immediate dispatch. */ placeBadge(badge: HTMLElement): void; + /** + * Notify the host that the disambiguation picker is opening or closing, so a + * size-constrained surface (e.g. the frameless aux input window) can grow to + * fit the options and shrink back afterwards. `itemCount` is the number of + * rows the picker will show. Optional; surfaces that don't need it omit it. + */ + onPickerVisibility?(visible: boolean, itemCount: number): void; } /** @@ -263,9 +270,15 @@ export class ChatSessionRoutingController extends Disposable { items.push(newItem); } - const picked = await this.quickInputService.pick(items, { - placeHolder: localize('chatSessionRouting.choosePlaceholder', "Choose where to send this request"), - }); + this.host.onPickerVisibility?.(true, items.length); + let picked: RouteChoiceItem | undefined; + try { + picked = await this.quickInputService.pick(items, { + placeHolder: localize('chatSessionRouting.choosePlaceholder', "Choose where to send this request"), + }); + } finally { + this.host.onPickerVisibility?.(false, items.length); + } if (!picked) { return undefined; }