From 4b5a5f83680aab972fb1b51cd30150be757a9d8f Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Mon, 20 Jul 2026 14:10:04 -0400 Subject: [PATCH 01/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] =?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/37] 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/37] 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/37] 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; } From c360601866e83a47f3e4b9e497712aa976172913 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 30 Jul 2026 16:19:56 -0400 Subject: [PATCH 12/37] feat(chat): introduce floating chat input window and enhance session routing - Added a toggle action for the floating chat input window in the sessions header when `chat.omni.enabled` is active. - Registered the chat input window service and integrated it with the existing chat functionality. - Enhanced session routing to utilize enriched session data (description, first/last requests, and last response) for improved matching. - Updated accessibility help to describe the floating chat input window when enabled. - Implemented clipping for long content fields in session routing to prevent overflow in prompts. - Added tests to verify the new functionality and ensure proper behavior of the routing logic with enriched content. --- .../actionWidget/browser/actionWidget.ts | 7 +- src/vs/sessions/SESSIONS_LIST.md | 2 + .../sessions/browser/sessions.contribution.ts | 20 +++ src/vs/sessions/sessions.common.main.ts | 1 + .../browser/actions/chatAccessibilityHelp.ts | 16 +- .../chat/browser/chat.shared.contribution.ts | 2 +- .../chatInputWindow.contribution.ts | 27 +++- .../chatInputWindow/chatInputWindowService.ts | 12 +- .../chatSessionRoutingController.ts | 147 +++++++++++++++++- .../speechToText/chatSpeechToTextService.ts | 8 +- .../browser/voiceClient/micCaptureService.ts | 18 ++- .../chat/browser/widgetHosts/chatQuick.ts | 27 ---- .../contrib/chat/common/chatInputWindow.ts | 2 + .../contrib/chat/common/sessionRouter.ts | 37 ++++- .../chatAccessibilityHelp.test.ts | 16 ++ .../chat/test/common/sessionRouter.test.ts | 45 +++++- 16 files changed, 320 insertions(+), 67 deletions(-) diff --git a/src/vs/platform/actionWidget/browser/actionWidget.ts b/src/vs/platform/actionWidget/browser/actionWidget.ts index a2f0f9f6ae4c56..0c82522fd2abe7 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.ts +++ b/src/vs/platform/actionWidget/browser/actionWidget.ts @@ -21,6 +21,7 @@ import { KeybindingWeight } from '../../keybinding/common/keybindingsRegistry.js import { inputActiveOptionBackground, registerColor } from '../../theme/common/colorRegistry.js'; import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; import { IListAccessibilityProvider } from '../../../base/browser/ui/list/listWidget.js'; +import { ILayoutService } from '../../layout/browser/layoutService.js'; registerColor( 'actionBar.toggledBackground', @@ -76,7 +77,8 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { constructor( @IContextViewService private readonly _contextViewService: IContextViewService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, - @IInstantiationService private readonly _instantiationService: IInstantiationService + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ILayoutService private readonly _layoutService: ILayoutService, ) { super(); } @@ -85,6 +87,7 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { const visibleContext = ActionWidgetContextKeys.Visible.bindTo(this._contextKeyService); const list = this._instantiationService.createInstance(ActionList, user, supportsPreview, items, delegate, accessibilityProvider, listOptions, anchor); + const targetContainer = container ?? (dom.isHTMLElement(anchor) ? this._layoutService.getContainer(dom.getWindow(anchor)) : undefined); this._contextViewService.showContextView({ getAnchor: () => anchor, render: (container: HTMLElement) => { @@ -96,7 +99,7 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { this._onWidgetClosed(didCancel); }, get anchorPosition() { return list.anchorPosition; }, - }, container, false); + }, targetContainer, false); } acceptSelected(preview?: boolean) { diff --git a/src/vs/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md index 95693e620be027..b16888d14a0baf 100644 --- a/src/vs/sessions/SESSIONS_LIST.md +++ b/src/vs/sessions/SESSIONS_LIST.md @@ -8,6 +8,8 @@ The sessions list is the primary navigation surface in the Agents Window. It occ The sessions list (`SessionsView` + `SessionsList`) displays every session known to `ISessionsManagementService`. Sessions are aggregated from all registered providers and shown in collapsible **sections**. The user can group, sort, filter, pin, and archive sessions. Selecting a session navigates to it. +When `chat.omni.enabled` is enabled, the Sessions header includes a `Codicon.commentDiscussionSparkle` action after **New Session** that toggles the floating chat input window. + ### Key Files | File | Purpose | diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index a52ee25d82b09f..4c9bbb3e261687 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -8,6 +8,8 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { IViewDescriptor, IViewsRegistry, Extensions as ViewContainerExtensions, WindowEnablement, ViewContainer, IViewContainersRegistry, ViewContainerLocation } from '../../../../workbench/common/views.js'; import { localize, localize2 } from '../../../../nls.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; @@ -20,6 +22,10 @@ import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING } from './views/sessionsList.js'; import { SessionsMouseNavigationContribution } from './sessionsMouseNavigation.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID } from '../../../../workbench/contrib/chat/common/chatInputWindow.js'; +import { OmniChatEnabledSettingId } from '../../../../workbench/contrib/chat/common/sessionRouter.js'; +import { Menus } from '../../../browser/menus.js'; const agentSessionsViewIcon = registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, localize('agentSessionsViewIcon', 'Icon for Agent Sessions View')); const AGENT_SESSIONS_VIEW_TITLE = localize2('agentSessions.view.label', "Sessions"); @@ -56,6 +62,20 @@ const sessionsViewPaneDescriptor: IViewDescriptor = { Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([sessionsViewPaneDescriptor], agentSessionsViewContainer); +MenuRegistry.appendMenuItem(Menus.SidebarSessionsHeader, { + command: { + id: CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID, + title: localize2('chat.toggleInputWindow', "Toggle Floating Chat Input Window"), + icon: Codicon.commentDiscussionSparkle, + }, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + ContextKeyExpr.equals(`config.${OmniChatEnabledSettingId}`, true) + ), +}); + Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'sessions', properties: { diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 8971c2b89cfac6..6355d4f53b7156 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -221,6 +221,7 @@ import '../workbench/contrib/speech/browser/speech.contribution.js'; // Chat import '../workbench/contrib/chat/browser/chat.shared.contribution.js'; +import '../workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.js'; //import '../workbench/contrib/inlineChat/browser/inlineChat.contribution.js'; import '../workbench/contrib/mcp/browser/mcp.contribution.js'; import '../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index fdc415f8ec4ea3..1686e68b8bdcbf 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -18,6 +18,8 @@ import { INLINE_CHAT_ID } from '../../../inlineChat/common/inlineChat.js'; import { TerminalContribCommandId } from '../../../terminal/terminalContribExports.js'; import { ChatContextKeyExprs, ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; +import { CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID } from '../../common/chatInputWindow.js'; +import { OmniChatEnabledSettingId } from '../../common/sessionRouter.js'; import { isStickyPromptHeaderShown } from '../promptTimeline/promptTimelineWidgetContrib.js'; import { FocusAgentSessionsAction } from '../agentSessions/agentSessionsActions.js'; import { IChatWidgetService } from '../chat.js'; @@ -73,7 +75,7 @@ export class AgentChatAccessibilityHelp implements IAccessibleViewImplementation } } -export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView' | 'chatInputWindow', keybindingService: IKeybindingService, supportsFileReferences: boolean, isSessionsWindow: boolean = false, stickyPromptHeaderShown: boolean = false): string { +export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView' | 'chatInputWindow', keybindingService: IKeybindingService, supportsFileReferences: boolean, isSessionsWindow: boolean = false, stickyPromptHeaderShown: boolean = false, inputWindowEnabled: boolean = false): 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.')); @@ -99,6 +101,9 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('workbench.action.openAgentsWindow', 'To open the Agents Window, invoke the Open Agents Window command{0}. In screen reader mode, this keybinding includes Alt to avoid conflicts with screen reader shortcuts.', '')); content.push(localize('workbench.action.chat.openAgentHostFolderPicker', 'When starting an agent session in a multi-root workspace, you can choose which root folder it runs in by invoking the Folder command{0}, then selecting a folder from the list.', '')); content.push(localize('chat.agentHostApprovalsPicker', 'When an agent session exposes approval presets, use Tab to reach the Approvals picker and choose how it handles workspace access, commands, and the internet.')); + if (type === 'panelChat' && inputWindowEnabled) { + content.push(localize('chat.toggleInputWindow.help', 'To open the floating chat input window, invoke the Toggle Floating Chat Input Window command{0}.', ``)); + } } content.push(localize('chat.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 run a new request.')); content.push(localize('chat.vscodePet', 'Type /vscode-pet to show or hide the VS Code pet above the input. Drag it horizontally to reposition it, or use Tab to focus it and the left and right arrow keys to move it. Press Enter or Space to show it some love.')); @@ -204,7 +209,14 @@ export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, edi const cachedPosition = inputEditor.getPosition(); inputEditor.getSupportedActions(); - const helpText = getAccessibilityHelpText(type, keybindingService, widget.supportsFileReferences, environmentService.isSessionsWindow, isStickyPromptHeaderShown(widget, configurationService)); + const helpText = getAccessibilityHelpText( + type, + keybindingService, + widget.supportsFileReferences, + environmentService.isSessionsWindow, + isStickyPromptHeaderShown(widget, configurationService), + configurationService.getValue(OmniChatEnabledSettingId) + ); return new AccessibleContentProvider( type === 'panelChat' ? AccessibleViewProviderId.PanelChat : type === 'inlineChat' ? AccessibleViewProviderId.InlineChat : type === 'agentView' ? AccessibleViewProviderId.AgentChat : type === 'chatInputWindow' ? AccessibleViewProviderId.ChatInputWindow : AccessibleViewProviderId.QuickChat, { type: AccessibleViewType.Help }, 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 33f5efef1c3e7c..833396f6aee120 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -254,7 +254,7 @@ configurationRegistry.registerConfiguration({ }, '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."), + markdownDescription: nls.localize('chat.omni.enabled', "Enables the floating chat input window and its entry points. Requests submitted from the window are scored against existing agent sessions and routed with an advisory badge."), default: false, tags: ['experimental'] }, 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 836ed3196b98f8..445931e5c8f344 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts @@ -6,22 +6,45 @@ import * as nls from '../../../../../nls.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.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'; +import { CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID, IChatInputWindowService } from '../../common/chatInputWindow.js'; +import { OmniChatEnabledSettingId } from '../../common/sessionRouter.js'; +import { ChatViewId } from '../chat.js'; // Registers the singleton implementation (side-effect import). import './chatInputWindowService.js'; +const inputWindowEnabled = ContextKeyExpr.and( + ChatContextKeys.enabled, + ContextKeyExpr.equals(`config.${OmniChatEnabledSettingId}`, true) +); + registerAction2(class extends Action2 { constructor() { super({ - id: 'workbench.action.chat.toggleInputWindow', + id: CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID, title: nls.localize2('chat.toggleInputWindow', "Toggle Floating Chat Input Window"), category: Categories.View, + icon: Codicon.commentDiscussionSparkle, f1: true, precondition: ChatContextKeys.enabled, + menu: [ + { + id: MenuId.CommandCenter, + group: 'navigation', + order: 4, + when: inputWindowEnabled, + }, + { + id: MenuId.ViewTitle, + group: 'navigation', + order: 0, + when: ContextKeyExpr.and(inputWindowEnabled, ContextKeyExpr.equals('view', ChatViewId)), + }, + ], }); } async run(accessor: ServicesAccessor): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index efcc324abf3dc7..10ecfcbfc3e57f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -176,7 +176,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind // compact ChatWidget. The response list is filtered out so only the input // box shows. Submission is intercepted via submitHandler (the routing // seam) and routed to the best-matching existing session. - this._renderChatWidget(auxiliaryWindow); + this._renderChatWidget(auxiliaryWindow, dragHandle); // 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. @@ -220,7 +220,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } } - private _renderChatWidget(auxiliaryWindow: IAuxiliaryWindow): void { + private _renderChatWidget(auxiliaryWindow: IAuxiliaryWindow, dragHandle: HTMLElement): 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` @@ -316,8 +316,13 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind const layout = () => widget.layout(parent.offsetHeight, parent.offsetWidth); layout(); + this._windowDisposables.add(dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => { + const windowChromeHeight = auxiliaryWindow.window.outerHeight - auxiliaryWindow.window.innerHeight; + const containerBorderHeight = auxiliaryWindow.container.offsetHeight - auxiliaryWindow.container.clientHeight; + const contentHeight = dragHandle.offsetHeight + widget.input.height.get() + containerBorderHeight; + auxiliaryWindow.window.resizeTo(auxiliaryWindow.window.outerWidth, contentHeight + windowChromeHeight); + })); this._windowDisposables.add(dom.addDisposableListener(auxiliaryWindow.window, 'resize', layout)); - this._windowDisposables.add(widget.onDidChangeHeight(() => layout())); } private _disposeWidget(): void { @@ -342,4 +347,3 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } registerSingleton(IChatInputWindowService, ChatInputWindowService, InstantiationType.Delayed); - diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts index c1843a0c01843f..70b02df4bc34bb 100644 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts @@ -5,6 +5,7 @@ import * as dom from '../../../../../base/browser/dom.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { IMarkdownString } from '../../../../../base/common/htmlContent.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'; @@ -16,7 +17,9 @@ 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 { IChatSessionHistoryItem, IChatSessionsService } from '../../common/chatSessionsService.js'; +import { heuristicScore, IRoutableSession, ISessionRouteResult, ISessionRouter, ROUTER_FIELD_CLIP_LENGTH } from '../../common/sessionRouter.js'; +import { AgentSessionProviders } from '../agentSessions/agentSessions.js'; import { IAgentSession, AgentSessionStatus } from '../agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; import { IChatWidgetService } from '../chat.js'; @@ -39,6 +42,13 @@ const ROUTE_AMBIGUITY_MARGIN = 0.2; /** Maximum number of options shown in the disambiguation picker. */ const ROUTE_MAX_CHOICES = 6; +/** + * How many top pre-ranked candidates get their conversation transcript fetched + * for the final content-aware score. Bounds how many session-content resolves a + * single submission triggers while still covering the plausible matches. + */ +const ROUTE_ENRICH_MAX_CANDIDATES = 5; + /** * 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 @@ -70,6 +80,36 @@ function statusToString(status: AgentSessionStatus): string { } } +/** Flatten a `string | IMarkdownString | undefined` field to plain text. */ +function markdownToText(value: string | IMarkdownString | undefined): string | undefined { + if (!value) { + return undefined; + } + const text = (typeof value === 'string' ? value : value.value).trim(); + return text || undefined; +} + +/** + * Extract plain text from a response history item by concatenating its markdown + * parts. Kept coarse and clipped: the router only needs a gist of the latest + * response, not a faithful render, so non-text parts (tools, trees, etc.) are + * ignored. Returns `undefined` when the response has no textual content. + */ +function historyResponseToText(item: Extract): string | undefined { + let text = ''; + for (const part of item.parts) { + if (part.kind === 'markdownContent') { + text += part.content.value; + // Enough to characterize the response; avoid walking a huge transcript. + if (text.length >= ROUTER_FIELD_CLIP_LENGTH * 2) { + break; + } + } + } + text = text.trim(); + return text || undefined; +} + /** * The surface (floating input window, quick chat, …) that hosts a routed chat * input. Supplies the widget being routed, its own scratch session to exclude @@ -117,6 +157,7 @@ export class ChatSessionRoutingController extends Disposable { private readonly debugOwner: string, @IChatService private readonly chatService: IChatService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @ISessionRouter private readonly sessionRouter: ISessionRouter, @IQuickInputService private readonly quickInputService: IQuickInputService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @@ -152,13 +193,22 @@ export class ChatSessionRoutingController extends Disposable { return true; } - const results = candidates.length ? await this._route(candidates, utterance, token) : []; + // Stage 1: cheaply pre-rank on in-memory metadata to pick a shortlist, then + // stage 2: enrich only that shortlist with conversation content before the + // final model score. This keeps transcript resolves bounded per submission. + const shortlist = this._preRankCandidates(candidates, utterance); + const enriched = shortlist.length ? await this._enrichCandidates(shortlist, token) : []; + if (token.isCancellationRequested) { + return true; + } + + const results = enriched.length ? await this._route(enriched, utterance, token) : []; if (token.isCancellationRequested) { return true; } - const target = this._resolveTarget(results, candidates); - this._beginPendingSend(target, results, candidates, query, utterance, attachedContext, cts); + const target = this._resolveTarget(results, enriched); + this._beginPendingSend(target, results, enriched, query, utterance, attachedContext, cts); return true; } @@ -211,9 +261,12 @@ export class ChatSessionRoutingController extends Disposable { } /** - * 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. + * Snapshot the current agent sessions as routing candidates. Excludes the + * host's own scratch session so it can never route to itself, and local chats: + * routing targets the headless, out-of-view agent sessions (cloud/background/ + * agent-host) this surface exists to fan requests out to, and those are the + * ones whose conversation content is available and meaningful to match on. + * Awaits the session model so a pending first-load/refresh isn't missed. */ private async _collectCandidateSessions(token: CancellationToken): Promise { try { @@ -226,7 +279,8 @@ export class ChatSessionRoutingController extends Disposable { } const ownResource = this.host.getOwnSessionResource()?.toString(); return this.agentSessionsService.model.sessions - .filter(session => session.resource.toString() !== ownResource) + .filter(session => session.resource.toString() !== ownResource + && session.providerType !== AgentSessionProviders.Local) .map(session => this._toRoutableSession(session)); } @@ -236,9 +290,86 @@ export class ChatSessionRoutingController extends Disposable { label: session.label, status: statusToString(session.status), lastActivity: session.timing?.lastRequestEnded ?? session.timing?.lastRequestStarted ?? session.timing?.created, + description: markdownToText(session.description), }; } + /** + * Stage 1: cheap, in-memory pre-rank to pick which candidates are worth + * enriching. Uses the offline token-overlap heuristic over the metadata we + * already hold, then keeps the top {@link ROUTE_ENRICH_MAX_CANDIDATES}. Any + * candidate the heuristic can't score (all zero, e.g. empty utterance) still + * passes through up to the cap so routing never starves on a weak pre-rank. + */ + private _preRankCandidates(candidates: IRoutableSession[], utterance: string): IRoutableSession[] { + if (candidates.length <= ROUTE_ENRICH_MAX_CANDIDATES) { + return candidates; + } + const byId = new Map(candidates.map(c => [c.sessionId, c])); + const ranked = heuristicScore({ utterance, sessions: candidates }); + return ranked + .slice(0, ROUTE_ENRICH_MAX_CANDIDATES) + .map(r => byId.get(r.sessionId)) + .filter((c): c is IRoutableSession => !!c); + } + + /** + * Stage 2: enrich the shortlisted candidates with conversation content (first + * request, most recent request, and a truncated most recent response) so the + * final score can match on what a session is actually about rather than just + * its title. Each fetch degrades independently: a session whose content can't + * be resolved is kept as-is on its metadata. + */ + private async _enrichCandidates(candidates: IRoutableSession[], token: CancellationToken): Promise { + return Promise.all(candidates.map(candidate => this._enrichCandidate(candidate, token))); + } + + private async _enrichCandidate(candidate: IRoutableSession, token: CancellationToken): Promise { + let resource: URI; + try { + resource = URI.parse(candidate.sessionId); + } catch { + return candidate; + } + try { + const session = await this.chatSessionsService.getOrCreateChatSession(resource, token); + if (token.isCancellationRequested) { + return candidate; + } + return this._applyHistory(candidate, session.history); + } catch (err) { + if (!token.isCancellationRequested) { + this.logService.trace('[chatSessionRouting] enriching candidate failed, using metadata only:', candidate.sessionId, err); + } + return candidate; + } + } + + /** Fold the first/most-recent request and most-recent response into a candidate. */ + private _applyHistory(candidate: IRoutableSession, history: readonly IChatSessionHistoryItem[]): IRoutableSession { + let firstRequest: string | undefined; + let lastRequest: string | undefined; + let lastResponse: string | undefined; + for (const item of history) { + if (item.type === 'request') { + const prompt = item.prompt.trim(); + if (prompt) { + firstRequest ??= prompt; + lastRequest = prompt; + } + } else { + const text = historyResponseToText(item); + if (text) { + lastResponse = text; + } + } + } + if (!firstRequest && !lastRequest && !lastResponse) { + return candidate; + } + return { ...candidate, firstRequest, lastRequest, lastResponse }; + } + /** * 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 diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts index c1f037db0b8fa0..27bee31a758009 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts @@ -32,6 +32,7 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatMessageRole, ILanguageModelsService } from '../../common/languageModels.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { createPcmCaptureNode } from '../pcmCaptureWorklet.js'; +import { getMediaCaptureWindow } from '../voiceClient/micCaptureService.js'; export const IChatSpeechToTextService = createDecorator('chatSpeechToTextService'); @@ -721,6 +722,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo if (this._state !== ChatSpeechToTextState.Idle) { return; } + const captureWindow = getMediaCaptureWindow(window); if (this._configurationService.getValue(ENABLED_SETTING) === false) { return; @@ -765,7 +767,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo let stream: MediaStream; try { - stream = await this._acquireStream(window); + stream = await this._acquireStream(captureWindow); } catch (err) { this._sessionErrorCode = this._sessionErrorCode || 'microphone'; this._logSessionTelemetry('error'); @@ -777,7 +779,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._mediaStream = stream; try { - await this._startBackendSession(window); + await this._startBackendSession(captureWindow); } catch (err) { this._teardown(); this._sessionErrorCode = this._sessionErrorCode || 'connect'; @@ -788,7 +790,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo } try { - await this._startCapture(window, stream); + await this._startCapture(captureWindow, stream); } catch (err) { // Capture setup (AudioContext/nodes) can fail after the mic and the // transcription session are already live; make sure both are torn diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts index e0902844da4617..5a4bf3d8236d76 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts @@ -14,9 +14,14 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { localize } from '../../../../../nls.js'; import { AgentsVoiceStorageKeys } from '../../../../contrib/agentsVoice/common/agentsVoice.js'; import { createPcmCaptureNode } from '../pcmCaptureWorklet.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; export const IMicCaptureService = createDecorator('micCaptureService'); +export function getMediaCaptureWindow(targetWindow: Window & typeof globalThis): Window & typeof globalThis { + return targetWindow === mainWindow ? targetWindow : mainWindow; +} + /** * Number of samples buffered in the capture worklet before a chunk is posted to * the main thread. Matches the buffer size previously used with @@ -239,7 +244,7 @@ export class MicCaptureService extends Disposable implements IMicCaptureService } prepare(window: Window & typeof globalThis): void { - this._window = window; + this._window = getMediaCaptureWindow(window); } async pttDown(turnId: string, passive: boolean = false): Promise { @@ -357,7 +362,8 @@ export class MicCaptureService extends Disposable implements IMicCaptureService } async startCapture(window: Window & typeof globalThis): Promise { - this._window = window; + const captureWindow = getMediaCaptureWindow(window); + this._window = captureWindow; if (this._isCapturing) { return; } const deviceId = this.storageService.get(AgentsVoiceStorageKeys.MicrophoneDevice, StorageScope.APPLICATION); const audioConstraints: MediaTrackConstraints = { @@ -372,7 +378,7 @@ export class MicCaptureService extends Disposable implements IMicCaptureService let micStream: MediaStream; try { - micStream = await window.navigator.mediaDevices.getUserMedia({ + micStream = await captureWindow.navigator.mediaDevices.getUserMedia({ audio: audioConstraints, }); } catch (err) { @@ -384,7 +390,7 @@ export class MicCaptureService extends Disposable implements IMicCaptureService this.logService.warn(`[mic] Preferred device ${deviceId.slice(0, 8)}… unavailable, falling back to default`); delete audioConstraints.deviceId; try { - micStream = await window.navigator.mediaDevices.getUserMedia({ + micStream = await captureWindow.navigator.mediaDevices.getUserMedia({ audio: audioConstraints, }); } catch (retryErr) { @@ -413,7 +419,7 @@ export class MicCaptureService extends Disposable implements IMicCaptureService } if (!this._micCtx) { - this._micCtx = new window.AudioContext({ sampleRate: 16000 }); + this._micCtx = new captureWindow.AudioContext({ sampleRate: 16000 }); } const ctx = this._micCtx; const source = ctx.createMediaStreamSource(micStream); @@ -423,7 +429,7 @@ export class MicCaptureService extends Disposable implements IMicCaptureService source.connect(analyser); this._analyserNode = analyser; - const { node } = await createPcmCaptureNode(window, ctx, MIC_CAPTURE_CHUNK_SIZE, samples => { + const { node } = await createPcmCaptureNode(captureWindow, ctx, MIC_CAPTURE_CHUNK_SIZE, samples => { const nowTs = Date.now(); const ptUpTs = this._diagPttUpTs; // A callback is a "drain" callback while we're still in the diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/chatQuick.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/chatQuick.ts index 93cc8376da2e06..9e5c9df96f38e4 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/chatQuick.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/chatQuick.ts @@ -15,7 +15,6 @@ 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'; @@ -32,8 +31,6 @@ 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; @@ -158,8 +155,6 @@ 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; @@ -175,7 +170,6 @@ 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(); } @@ -205,9 +199,6 @@ 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(() => { @@ -254,14 +245,6 @@ 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, @@ -272,16 +255,6 @@ 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 bf41d16ff46a17..7e858d75ff195c 100644 --- a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts +++ b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts @@ -6,6 +6,8 @@ import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { Event } from '../../../../base/common/event.js'; +export const CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID = 'workbench.action.chat.toggleInputWindow'; + /** * Default dimensions for the floating chat input window. */ diff --git a/src/vs/workbench/contrib/chat/common/sessionRouter.ts b/src/vs/workbench/contrib/chat/common/sessionRouter.ts index 72a8f2f28fc187..71667c597d725e 100644 --- a/src/vs/workbench/contrib/chat/common/sessionRouter.ts +++ b/src/vs/workbench/contrib/chat/common/sessionRouter.ts @@ -29,6 +29,14 @@ export interface IRoutableSession { readonly status?: string; /** Epoch milliseconds of the last activity, when known. */ readonly lastActivity?: number; + /** Provider-supplied session summary/description, when known. */ + readonly description?: string; + /** The session's opening user request, when known. */ + readonly firstRequest?: string; + /** The session's most recent user request, when known. */ + readonly lastRequest?: string; + /** The session's most recent response (already truncated by the caller), when known. */ + readonly lastResponse?: string; } /** A single scored candidate produced by the router, sorted best-first. */ @@ -73,6 +81,18 @@ export interface ISessionRouterMessage { readonly content: string; } +/** + * Upper bound on any single free-text field embedded in the router prompt, so + * one verbose session (e.g. a long response) can't dominate or blow the prompt. + */ +export const ROUTER_FIELD_CLIP_LENGTH = 240; + +/** Collapse whitespace and clip a free-text field for embedding in the prompt. */ +function clip(text: string, max: number = ROUTER_FIELD_CLIP_LENGTH): string { + const normalized = text.replace(/\s+/g, ' ').trim(); + return normalized.length > max ? `${normalized.slice(0, max)}...` : normalized; +} + /** * 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 @@ -84,11 +104,16 @@ export function buildRouterMessages(request: ISessionRouteRequest): ISessionRout if (session.repo) { parts.push(`repo=${session.repo}`); } if (session.cwd) { parts.push(`cwd=${session.cwd}`); } if (session.status) { parts.push(`status=${session.status}`); } + if (session.description) { parts.push(`summary=${JSON.stringify(clip(session.description))}`); } + if (session.firstRequest) { parts.push(`firstRequest=${JSON.stringify(clip(session.firstRequest))}`); } + if (session.lastRequest) { parts.push(`lastRequest=${JSON.stringify(clip(session.lastRequest))}`); } + if (session.lastResponse) { parts.push(`lastResponse=${JSON.stringify(clip(session.lastResponse))}`); } return `- ${parts.join(' ')}`; }).join('\n'); const system = [ 'You route a user request to the coding session it most likely refers to.', + 'Each candidate may include a summary plus its first request, most recent request, and most recent response; weigh these more heavily than the name when present.', '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}]', @@ -156,13 +181,15 @@ export function parseRouterResponse(text: string, validSessionIds: ReadonlySet(); for (const field of fields) { diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index bfd5cfdf5a6fcd..b697b5b8a9219a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts @@ -56,4 +56,20 @@ suite('Chat Accessibility Help', () => { byDefault: false, }); }); + + test('only describes the floating input window when enabled in panel chat', () => { + const keybindingService = { + lookupKeybindings: () => [], + } as unknown as IKeybindingService; + const describesInputWindow = (enabled: boolean) => + getAccessibilityHelpText('panelChat', keybindingService, true, false, false, enabled).includes('floating chat input window'); + + assert.deepStrictEqual({ + enabled: describesInputWindow(true), + disabled: describesInputWindow(false), + }, { + enabled: true, + disabled: false, + }); + }); }); 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 dd7c9611833bb8..19d1df7cc45601 100644 --- a/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { buildRouterMessages, heuristicScore, ISessionRouteRequest, parseRouterResponse } from '../../common/sessionRouter.js'; +import { buildRouterMessages, heuristicScore, ISessionRouteRequest, parseRouterResponse, ROUTER_FIELD_CLIP_LENGTH } from '../../common/sessionRouter.js'; suite('SessionRouter helpers', () => { @@ -29,6 +29,25 @@ suite('SessionRouter helpers', () => { assert.ok(messages[1].content.includes('id=s2')); }); + test('buildRouterMessages embeds enriched conversation content', () => { + const messages = buildRouterMessages({ + utterance: 'ship it', + sessions: [{ + sessionId: 's1', + label: 'voice narration', + description: 'Adds dictation onboarding', + firstRequest: 'add a voice onboarding dialog', + lastRequest: 'tweak the countdown copy', + lastResponse: 'Updated the countdown to read "sending in Ns".' + }] + }); + const user = messages[1].content; + assert.ok(user.includes('summary=')); + assert.ok(user.includes('firstRequest=')); + assert.ok(user.includes('lastRequest=')); + assert.ok(user.includes('lastResponse=')); + }); + 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'])); @@ -49,16 +68,28 @@ suite('SessionRouter helpers', () => { 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; + test('heuristicScore matches on enriched content, not just the label', () => { const ranked = heuristicScore({ - utterance: 'can you keep working on the voice narration session please', + utterance: 'update the authentication token refresh logic', sessions: [ - { sessionId: 's1', label: 'voice narration', repo: 'meganrogge/momentum-map', status: 'idle' }, - { sessionId: 's2', label: 'docs cleanup', repo: 'microsoft/vscode-docs' } + { sessionId: 's1', label: 'session one', lastRequest: 'fix the authentication token refresh logic' }, + { sessionId: 's2', label: 'session two', lastRequest: 'restyle the settings page' } ] }); assert.strictEqual(ranked[0].sessionId, 's1'); - assert.ok(ranked[0].confidence >= ROUTE_CONFIDENCE_THRESHOLD, `expected >= ${ROUTE_CONFIDENCE_THRESHOLD}, got ${ranked[0].confidence}`); + assert.ok(ranked[0].confidence > ranked[1].confidence); + }); + + test('buildRouterMessages clips overlong content fields', () => { + const longResponse = 'x '.repeat(400); + const user = buildRouterMessages({ + utterance: 'hi', + sessions: [{ sessionId: 's1', label: 'l', lastResponse: longResponse }] + })[1].content; + const match = /lastResponse=("(?:[^"\\]|\\.)*")/.exec(user); + assert.ok(match, 'expected a lastResponse field'); + const value: string = JSON.parse(match![1]); + assert.ok(value.length <= ROUTER_FIELD_CLIP_LENGTH + 3, `expected clipped, got length ${value.length}`); + assert.ok(value.endsWith('...')); }); }); From 5c23295a7af60b0e231b0c3dd795c213646ea618 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 30 Jul 2026 16:59:40 -0400 Subject: [PATCH 13/37] fix(chat): polish floating input window Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../electron-main/auxiliaryWindow.ts | 7 +- .../platform/windows/electron-main/windows.ts | 4 +- src/vs/workbench/contrib/chat/browser/chat.ts | 3 + .../chatInputWindow/chatInputWindowService.ts | 94 ++++++++++++++----- .../contrib/chat/browser/widget/chatWidget.ts | 2 + .../browser/widget/input/chatInputPart.ts | 6 ++ .../widget/input/chatInputPickerActionItem.ts | 2 +- .../modelPicker/modelPickerActionItem.ts | 3 + .../input/modelPicker/modelPickerWidget.ts | 49 +++++++--- 9 files changed, 132 insertions(+), 38 deletions(-) diff --git a/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindow.ts b/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindow.ts index b2c628e75ab3f7..135f174aed3ade 100644 --- a/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindow.ts +++ b/src/vs/platform/auxiliaryWindow/electron-main/auxiliaryWindow.ts @@ -99,8 +99,11 @@ export class AuxiliaryWindow extends BaseWindow implements IAuxiliaryWindow { this.lifecycleMainService.registerAuxWindow(this); // Hide macOS traffic light buttons for frameless windows - if (isMacintosh && options?.frame === false) { - window.setWindowButtonVisibility(false); + if (options?.frame === false) { + window.setMinimumSize(1, 1); + if (isMacintosh) { + window.setWindowButtonVisibility(false); + } } // Disable resizing for non-resizable windows diff --git a/src/vs/platform/windows/electron-main/windows.ts b/src/vs/platform/windows/electron-main/windows.ts index 6e312e95377c44..7c4f02efdfa374 100644 --- a/src/vs/platform/windows/electron-main/windows.ts +++ b/src/vs/platform/windows/electron-main/windows.ts @@ -243,8 +243,8 @@ export function defaultBrowserWindowOptions(accessor: ServicesAccessor, windowSt options.frame = false; options.titleBarStyle = undefined; options.titleBarOverlay = undefined; - options.minWidth = undefined; - options.minHeight = undefined; + options.minWidth = 1; + options.minHeight = 1; } if (overrides?.backgroundColor) { diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index f8c874717626fd..5f727a0f6285a4 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -7,6 +7,7 @@ import { IMouseWheelEvent } from '../../../../base/browser/mouseEvent.js'; import { Event } from '../../../../base/common/event.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { AnchorPosition } from '../../../../base/common/layout.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { Selection } from '../../../../editor/common/core/selection.js'; import { EditDeltaInfo } from '../../../../editor/common/textModelEditSource.js'; @@ -314,6 +315,8 @@ export interface IChatWidgetViewOptions { * instead of silently dropping them. */ submitHandler?: (query: string, mode: ChatModeKind, attachedContext?: IChatRequestVariableEntry[]) => Promise; + onDidChangeModelPickerVisibility?: (visible: boolean) => void | Promise; + inputPickerPosition?: AnchorPosition; /** * Whether we are running in the sessions window. diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index 10ecfcbfc3e57f..45dffeea7af5a5 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -5,6 +5,7 @@ import * as dom from '../../../../../base/browser/dom.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { AnchorPosition } from '../../../../../base/common/layout.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { mainWindow } from '../../../../../base/browser/window.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; @@ -34,6 +35,8 @@ const CHAT_INPUT_WINDOW_PICKER_ROW_HEIGHT = 22; 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; +const CHAT_INPUT_WINDOW_MODEL_PICKER_HEIGHT = 420; +const CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT = 44; /** * Hosts a frameless, always-on-top auxiliary window containing the full chat @@ -61,6 +64,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private _openOperation: Promise | undefined; /** Window height (outer) captured before growing to fit the routing picker; restored on close. */ private _preExpandHeight: number | undefined; + private _actionWidgetRestoreHeight: number | undefined; get isOpen(): boolean { return !!this._window; @@ -121,11 +125,11 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind bounds, alwaysOnTop: true, frameless: true, - transparent: false, + transparent: true, disableFullscreen: true, nativeTitlebar: false, noBackgroundThrottling: true, - backgroundColor: this.themeService.getColorTheme().getColor(editorBackground)?.toString() ?? '#1e1e1e', + backgroundColor: '#00000000', }); this._window = auxiliaryWindow; @@ -138,6 +142,8 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind : localize('chatInputWindow.title', "Chat Input"); auxiliaryWindow.container.style.overflow = 'hidden'; + auxiliaryWindow.container.style.backgroundColor = 'transparent'; + auxiliaryWindow.container.style.border = 'none'; auxiliaryWindow.window.document.body.style.setProperty('margin', '0', 'important'); this._windowDisposables.clear(); @@ -152,31 +158,36 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind 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'); + contentSurface.style.backgroundColor = inputBg; + contentSurface.style.border = `1px solid ${inputBd}`; + auxiliaryWindow.window.document.body.style.setProperty('background-color', 'transparent', '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'; + const contentSurface = dom.append(auxiliaryWindow.container, dom.$('.chat-input-window-content')); + contentSurface.style.boxSizing = 'border-box'; + contentSurface.style.width = '100%'; + contentSurface.style.height = `${CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT}px`; + contentSurface.style.flex = '0 0 auto'; + contentSurface.style.overflow = 'hidden'; + contentSurface.style.display = 'flex'; + contentSurface.style.flexDirection = 'column'; + const dragHandle = dom.append(contentSurface, dom.$('.chat-input-window-drag-handle')); + (dragHandle.style as CSSStyleDeclaration & { '-webkit-app-region': string })['-webkit-app-region'] = 'drag'; + dragHandle.style.height = '12px'; dragHandle.style.width = '100%'; dragHandle.style.flexShrink = '0'; dragHandle.style.cursor = 'grab'; - auxiliaryWindow.container.style.display = 'flex'; - auxiliaryWindow.container.style.flexDirection = 'column'; + applyThemeColors(); + this._windowDisposables.add(this.themeService.onDidColorThemeChange(() => applyThemeColors())); // 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 routed to the best-matching existing session. - this._renderChatWidget(auxiliaryWindow, dragHandle); + this._renderChatWidget(auxiliaryWindow, contentSurface, dragHandle); // 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. @@ -220,12 +231,13 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } } - private _renderChatWidget(auxiliaryWindow: IAuxiliaryWindow, dragHandle: HTMLElement): void { + private _renderChatWidget(auxiliaryWindow: IAuxiliaryWindow, contentSurface: HTMLElement, dragHandle: HTMLElement): 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')); + const parent = dom.append(contentSurface, dom.$('.interactive-session')); + (parent.style as CSSStyleDeclaration & { '-webkit-app-region': string })['-webkit-app-region'] = 'no-drag'; parent.style.flex = '1 1 auto'; parent.style.minHeight = '0'; parent.style.width = '100%'; @@ -259,6 +271,8 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind // route it to the best-matching existing session (or a new one), // forwarding any explicit attachments on the input. submitHandler: (query, mode, attachedContext) => this._routingController?.handleSubmit(query, mode, attachedContext) ?? Promise.resolve(false), + onDidChangeModelPickerVisibility: visible => this._layoutForModelPicker(auxiliaryWindow, visible), + inputPickerPosition: AnchorPosition.BELOW, }, { inputEditorBackground: inputBackground, @@ -282,7 +296,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind widget, getOwnSessionResource: () => this._modelRef?.object.sessionResource, placeBadge: (badge) => { - const container = this._window?.container; + const container = this._widgetParent?.parentElement; if (container && this._widgetParent) { container.insertBefore(badge, this._widgetParent); } @@ -297,6 +311,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } try { if (visible) { + auxiliaryWindow.container.style.height = '100%'; if (this._preExpandHeight === undefined) { this._preExpandHeight = win.outerHeight; } @@ -305,8 +320,10 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind const screenBottom = win.screen.availHeight; const maxHeight = Math.max(screenBottom - win.screenY, this._preExpandHeight); win.resizeTo(win.outerWidth, Math.min(desired, maxHeight)); + win.dispatchEvent(new win.Event('resize')); } else if (this._preExpandHeight !== undefined) { win.resizeTo(win.outerWidth, this._preExpandHeight); + win.dispatchEvent(new win.Event('resize')); this._preExpandHeight = undefined; } } catch { /* resize may not be supported */ } @@ -318,25 +335,60 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind layout(); this._windowDisposables.add(dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => { const windowChromeHeight = auxiliaryWindow.window.outerHeight - auxiliaryWindow.window.innerHeight; - const containerBorderHeight = auxiliaryWindow.container.offsetHeight - auxiliaryWindow.container.clientHeight; - const contentHeight = dragHandle.offsetHeight + widget.input.height.get() + containerBorderHeight; + const containerBorderHeight = contentSurface.offsetHeight - contentSurface.clientHeight; + const contentHeight = dragHandle.offsetHeight + (widget.input.inputContainerElement?.offsetHeight ?? CHAT_INPUT_WINDOW_DEFAULT_HEIGHT) + containerBorderHeight; + contentSurface.style.height = `${contentHeight}px`; auxiliaryWindow.window.resizeTo(auxiliaryWindow.window.outerWidth, contentHeight + windowChromeHeight); + const centeredX = Math.round(mainWindow.screenX + (mainWindow.outerWidth - auxiliaryWindow.window.outerWidth) / 2); + const centeredY = Math.round(mainWindow.screenY + (mainWindow.outerHeight - contentHeight) / 2); + auxiliaryWindow.window.moveTo(centeredX, centeredY); })); this._windowDisposables.add(dom.addDisposableListener(auxiliaryWindow.window, 'resize', layout)); } + private async _layoutForModelPicker(auxiliaryWindow: IAuxiliaryWindow, visible: boolean): Promise { + const win = auxiliaryWindow.window; + if (visible) { + if (this._actionWidgetRestoreHeight !== undefined) { + return; + } + + this._actionWidgetRestoreHeight = win.outerHeight; + const desiredHeight = Math.max(win.outerHeight, CHAT_INPUT_WINDOW_MODEL_PICKER_HEIGHT); + const windowChromeHeight = win.outerHeight - win.innerHeight; + win.resizeTo(win.outerWidth, desiredHeight); + await this._waitForWindowHeight(auxiliaryWindow, desiredHeight - windowChromeHeight); + win.focus(); + } else if (this._actionWidgetRestoreHeight !== undefined) { + const restoreHeight = this._actionWidgetRestoreHeight; + this._actionWidgetRestoreHeight = undefined; + win.resizeTo(win.outerWidth, restoreHeight); + win.dispatchEvent(new win.Event('resize')); + } + } + + private async _waitForWindowHeight(auxiliaryWindow: IAuxiliaryWindow, minimumInnerHeight: number): Promise { + for (let attempt = 0; attempt < 30 && this._window === auxiliaryWindow; attempt++) { + if (auxiliaryWindow.window.innerHeight >= minimumInnerHeight) { + return; + } + await new Promise(resolve => dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => resolve())); + } + } + private _disposeWidget(): void { this._routingController = undefined; this._widgetParent = undefined; this._preExpandHeight = undefined; + this._actionWidgetRestoreHeight = undefined; this._modelRef?.dispose(); this._modelRef = undefined; } private _defaultBounds(): IRectangle { - // Center horizontally within the main VS Code window, near the bottom. + // Center the omni bar within the main VS Code window. 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; + const y = Math.round(mainWindow.screenY + (mainWindow.outerHeight - CHAT_INPUT_WINDOW_DEFAULT_HEIGHT) / 2); return { x, y, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index bb55650163eb97..a25bf7998eec1a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -2049,6 +2049,8 @@ export class ChatWidget extends Disposable implements IChatWidget { workspacePickerDelegate: this.viewOptions.workspacePickerDelegate, isSessionsWindow: this.viewOptions.isSessionsWindow, onDidChangeInputOnboardingVisible: visible => this.setInputOnboardingVisible(visible), + onDidChangeModelPickerVisibility: this.viewOptions.onDidChangeModelPickerVisibility, + inputPickerPosition: this.viewOptions.inputPickerPosition, }; if (this.viewModel?.editing) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 2a81d98b9d8816..c9be394d2bc2ae 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -24,6 +24,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; +import { AnchorPosition } from '../../../../../../base/common/layout.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; @@ -251,6 +252,8 @@ export interface IChatInputPartOptions { */ inputPartHorizontalPadding?: number; onDidChangeInputOnboardingVisible?: (visible: boolean) => void; + onDidChangeModelPickerVisibility?: (visible: boolean) => void | Promise; + inputPickerPosition?: AnchorPosition; } export interface IWorkingSetEntry { @@ -1170,6 +1173,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge isCacheWarm: () => (this._widget?.viewModel?.model.getRequests().length ?? 0) > 0, getPresentationOptions: () => this._getModelPickerPresentationOptions(), modelConfiguration: this._modelConfigStore, + onDidChangeVisibility: this.options.onDidChangeModelPickerVisibility, + anchorPosition: this.options.inputPickerPosition, }; } @@ -3156,6 +3161,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge getOverflowAnchor: () => this.inputActionsToolbar.getElement(), actionContext: { widget }, compact: derived(reader => this._stableInputPartWidth.read(reader) < CHAT_INPUT_PICKER_COLLAPSE_WIDTH), + listOptions: this.options.inputPickerPosition === undefined ? undefined : { anchorPosition: this.options.inputPickerPosition }, }; const primarySessionPickerOptions: IChatInputPickerOptions = { ...pickerOptions, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts index ef1926631dcf54..305b74b0be05ca 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts @@ -32,8 +32,8 @@ export interface IChatInputPickerOptions { export function withChatInputPickerMotion(listOptions: IActionListOptions | undefined): IActionListOptions { return { - ...withActionWidgetDropdownMotion(listOptions), anchorPosition: AnchorPosition.ABOVE, + ...withActionWidgetDropdownMotion(listOptions), }; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts index 973051a341d37b..253fc79c59c227 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts @@ -11,6 +11,7 @@ import { BaseActionViewItem } from '../../../../../../../base/browser/ui/actionb import { IAction } from '../../../../../../../base/common/actions.js'; import { IStringDictionary } from '../../../../../../../base/common/collections.js'; import { Event } from '../../../../../../../base/common/event.js'; +import { AnchorPosition } from '../../../../../../../base/common/layout.js'; import { MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../../../../../base/common/observable.js'; import { localize } from '../../../../../../../nls.js'; @@ -73,6 +74,8 @@ export interface IModelPickerDelegate { * writes configuration through the global {@link ILanguageModelsService}. */ readonly modelConfiguration?: IModelConfigurationAccess; + onDidChangeVisibility?(visible: boolean): void | Promise; + readonly anchorPosition?: AnchorPosition; } /** diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts index 139e700436de6e..e4e35a7d33f84f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts @@ -14,6 +14,7 @@ import { IStringDictionary } from '../../../../../../../base/common/collections. import { Codicon } from '../../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { KeyCode } from '../../../../../../../base/common/keyCodes.js'; +import { AnchorPosition } from '../../../../../../../base/common/layout.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; import { disposableTimeout } from '../../../../../../../base/common/async.js'; import { autorun, IObservable } from '../../../../../../../base/common/observable.js'; @@ -105,6 +106,8 @@ export class ModelPickerWidget extends Disposable { private _workspaceTrustInitialized = false; private _activatingAfterTrust = false; private readonly _activatingTimer = this._register(new MutableDisposable()); + private readonly _pendingAuxiliaryRelayout = this._register(new MutableDisposable()); + private _showRequestId = 0; private _domNode: HTMLElement | undefined; private _badgeIcon: HTMLElement | undefined; @@ -411,6 +414,9 @@ export class ModelPickerWidget extends Disposable { return; } if (this._nameButton?.getAttribute('aria-expanded') === 'true') { + this._showRequestId++; + this._nameButton.setAttribute('aria-expanded', 'false'); + this._delegate.onDidChangeVisibility?.(false); this._actionWidgetService.hide(true); return; } @@ -527,6 +533,7 @@ export class ModelPickerWidget extends Disposable { void this._openerService.open(uri, { allowCommands: true }); }, minWidth: 200, + anchorPosition: this._delegate.anchorPosition ?? AnchorPosition.ABOVE, }); const previouslyFocusedElement = dom.getActiveElement(); @@ -536,8 +543,10 @@ export class ModelPickerWidget extends Disposable { action.run(); }, onHide: () => { + this._showRequestId++; hoverDisposables.dispose(); this._nameButton?.setAttribute('aria-expanded', 'false'); + this._delegate.onDidChangeVisibility?.(false); if (dom.isHTMLElement(previouslyFocusedElement)) { previouslyFocusedElement.focus(); } @@ -545,18 +554,34 @@ export class ModelPickerWidget extends Disposable { }; this._nameButton?.setAttribute('aria-expanded', 'true'); - - this._actionWidgetService.show( - 'ChatModelPicker', - false, - items, - delegate, - anchorElement, - undefined, - [], - getModelPickerAccessibilityProvider(), - listOptions - ); + const showRequestId = ++this._showRequestId; + const showActionWidget = () => { + if (showRequestId !== this._showRequestId || this._nameButton?.getAttribute('aria-expanded') !== 'true') { + return; + } + this._actionWidgetService.show( + 'ChatModelPicker', + false, + items, + delegate, + anchorElement, + undefined, + [], + getModelPickerAccessibilityProvider(), + listOptions + ); + if (this._delegate.onDidChangeVisibility) { + this._pendingAuxiliaryRelayout.value = dom.scheduleAtNextAnimationFrame(dom.getWindow(anchorElement), () => { + this._actionWidgetService.updateItems(items); + }); + } + }; + const visibilityChange = this._delegate.onDidChangeVisibility?.(true); + if (visibilityChange) { + void visibilityChange.then(showActionWidget); + } else { + showActionWidget(); + } } private _updateBadge(): void { From 798a0efe4a6b819a14a4ca83764c51f7b9e4dd04 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 30 Jul 2026 17:18:48 -0400 Subject: [PATCH 14/37] fix(chat): single rounded box for floating input window The content surface stacked a second bordered/rounded box around the chat input's own rounded container, producing mismatched concentric corners. Make the surface an invisible transparent host with a symmetric padding ring (which also serves as the drag region), so only the input box's own consistent rounded corners show. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chatInputWindow/chatInputWindowService.ts | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index 45dffeea7af5a5..d11cb7e424a112 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -18,7 +18,7 @@ import { IAuxiliaryWindowService, IAuxiliaryWindow } from '../../../../services/ 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 { inputBackground } from '../../../../../platform/theme/common/colors/inputColors.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { localize } from '../../../../../nls.js'; import { ChatAgentLocation } from '../../common/constants.js'; @@ -36,7 +36,9 @@ 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; const CHAT_INPUT_WINDOW_MODEL_PICKER_HEIGHT = 420; -const CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT = 44; +const CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT = 48; +/** Symmetric transparent padding around the input box; also the draggable ring. */ +const CHAT_INPUT_WINDOW_SURFACE_PADDING = 8; /** * Hosts a frameless, always-on-top auxiliary window containing the full chat @@ -148,38 +150,32 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind 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). + // Keep the window body itself fully transparent; the only visible chrome + // is the chat input box, which brings its own background, border and + // rounded corners. Re-assert transparency on theme changes. 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); - contentSurface.style.backgroundColor = inputBg; - contentSurface.style.border = `1px solid ${inputBd}`; auxiliaryWindow.window.document.body.style.setProperty('background-color', 'transparent', 'important'); }; - // 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. + // The content surface is an invisible host: it stays transparent and adds + // a small symmetric padding ring around the input. That ring both centers + // the input box and — because a frameless window can only be moved through + // a `-webkit-app-region: drag` region — provides the draggable area. The + // input box itself is marked no-drag inside the widget so it stays + // interactive, leaving only the surrounding ring grabbable. const contentSurface = dom.append(auxiliaryWindow.container, dom.$('.chat-input-window-content')); contentSurface.style.boxSizing = 'border-box'; contentSurface.style.width = '100%'; contentSurface.style.height = `${CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT}px`; contentSurface.style.flex = '0 0 auto'; - contentSurface.style.overflow = 'hidden'; + contentSurface.style.overflow = 'visible'; + contentSurface.style.backgroundColor = 'transparent'; + contentSurface.style.border = 'none'; + contentSurface.style.padding = `${CHAT_INPUT_WINDOW_SURFACE_PADDING}px`; + contentSurface.style.cursor = 'grab'; contentSurface.style.display = 'flex'; contentSurface.style.flexDirection = 'column'; - const dragHandle = dom.append(contentSurface, dom.$('.chat-input-window-drag-handle')); - (dragHandle.style as CSSStyleDeclaration & { '-webkit-app-region': string })['-webkit-app-region'] = 'drag'; - dragHandle.style.height = '12px'; - dragHandle.style.width = '100%'; - dragHandle.style.flexShrink = '0'; - dragHandle.style.cursor = 'grab'; + (contentSurface.style as CSSStyleDeclaration & { '-webkit-app-region': string })['-webkit-app-region'] = 'drag'; applyThemeColors(); this._windowDisposables.add(this.themeService.onDidColorThemeChange(() => applyThemeColors())); @@ -187,7 +183,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind // compact ChatWidget. The response list is filtered out so only the input // box shows. Submission is intercepted via submitHandler (the routing // seam) and routed to the best-matching existing session. - this._renderChatWidget(auxiliaryWindow, contentSurface, dragHandle); + this._renderChatWidget(auxiliaryWindow, contentSurface); // 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. @@ -231,11 +227,12 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } } - private _renderChatWidget(auxiliaryWindow: IAuxiliaryWindow, contentSurface: HTMLElement, dragHandle: HTMLElement): void { + private _renderChatWidget(auxiliaryWindow: IAuxiliaryWindow, contentSurface: HTMLElement): 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. + // wrapper here. Mark it no-drag so the visible input box stays fully + // interactive; only the transparent padding ring around it drags. const parent = dom.append(contentSurface, dom.$('.interactive-session')); (parent.style as CSSStyleDeclaration & { '-webkit-app-region': string })['-webkit-app-region'] = 'no-drag'; parent.style.flex = '1 1 auto'; @@ -335,8 +332,13 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind layout(); this._windowDisposables.add(dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => { const windowChromeHeight = auxiliaryWindow.window.outerHeight - auxiliaryWindow.window.innerHeight; - const containerBorderHeight = contentSurface.offsetHeight - contentSurface.clientHeight; - const contentHeight = dragHandle.offsetHeight + (widget.input.inputContainerElement?.offsetHeight ?? CHAT_INPUT_WINDOW_DEFAULT_HEIGHT) + containerBorderHeight; + // Size the surface to the input box plus a symmetric padding ring on + // every side, so the box is centered and its rounded corners aren't + // clipped. + const inputHeight = widget.input.inputContainerElement?.getBoundingClientRect().height; + const contentHeight = inputHeight === undefined + ? CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT + : Math.ceil(inputHeight + 2 * CHAT_INPUT_WINDOW_SURFACE_PADDING); contentSurface.style.height = `${contentHeight}px`; auxiliaryWindow.window.resizeTo(auxiliaryWindow.window.outerWidth, contentHeight + windowChromeHeight); const centeredX = Math.round(mainWindow.screenX + (mainWindow.outerWidth - auxiliaryWindow.window.outerWidth) / 2); From 563b1fa42ec978d29cfde4ab39359f5f26b49c3a Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 30 Jul 2026 17:31:27 -0400 Subject: [PATCH 15/37] Polish omni-chat: inside close button, cursor focus, remove attach, Quick Chat width - Move close (X) action inside the input box via MenuId.ChatExecute - Focus input so the text cursor renders on open - Hide the attach-context (+) button in the omni-chat window - Remove dead ChatInputWindowSide menu and import - Clamp model-detail hover to the viewport with scroll - Widen default window to Quick Chat width (min(innerWidth * 0.62, 600)) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../actionWidget/browser/actionList.ts | 31 +++++++++++++++---- src/vs/platform/actions/common/actions.ts | 1 - .../browser/actions/chatContextActions.ts | 2 ++ .../chatInputWindow.contribution.ts | 9 ++++-- .../chatInputWindow/chatInputWindowService.ts | 15 ++++++--- .../contrib/chat/common/chatInputWindow.ts | 3 +- 6 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index a5135121018bd4..8dd27cefdfb3df 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -1856,29 +1856,48 @@ export class ActionListWidget extends Disposable { // Position: prefer right side, fall back to left if not enough space const viewportWidth = targetWindow.innerWidth; + const viewportHeight = targetWindow.innerHeight; const spaceRight = viewportWidth - anchorRect.right; const spaceLeft = parentRect.left; const panelWidth = maxWidth + 10; // account for border/padding + const margin = 8; const gap = 4; + let containerLeft: number; if (spaceRight >= panelWidth || spaceRight >= spaceLeft) { - this._submenuContainer.style.left = `${parentRect.right - parentRect.left + gap}px`; + containerLeft = parentRect.right - parentRect.left + gap; } else { - this._submenuContainer.style.left = `${-panelWidth - gap}px`; + containerLeft = -panelWidth - gap; + } + // Clamp horizontally so the panel stays within the target window even + // when it's small (e.g. the floating chat input window), instead of + // spilling off the edge and being clipped. + let viewportLeft = parentRect.left + containerLeft; + if (viewportLeft + panelWidth > viewportWidth - margin) { + viewportLeft = viewportWidth - margin - panelWidth; + } + if (viewportLeft < margin) { + viewportLeft = margin; } + this._submenuContainer.style.left = `${viewportLeft - parentRect.left}px`; + const hoverHeaderHeight = hoverHeader ? hoverHeader.offsetHeight : 0; const totalPanelHeight = totalHeight + hoverHeaderHeight; - const viewportHeight = targetWindow.innerHeight; const anchorHeight = anchorRect.height; let top = anchorRect.top - parentRect.top + (anchorHeight - totalPanelHeight) / 2; const panelBottom = parentRect.top + top + totalPanelHeight; if (panelBottom > viewportHeight) { - top -= (panelBottom - viewportHeight + 8); + top -= (panelBottom - viewportHeight + margin); } - if (parentRect.top + top < 0) { - top = -parentRect.top; + if (parentRect.top + top < margin) { + top = margin - parentRect.top; } this._submenuContainer.style.top = `${top}px`; + // Constrain the panel to the window and let overflowing content scroll, + // so a tall hover (e.g. model details) is never cut off by the bounds. + this._submenuContainer.style.maxHeight = `${viewportHeight - margin * 2}px`; + this._submenuContainer.style.overflowY = 'auto'; + this._submenuContainer.style.overflowX = 'hidden'; } private _hideSubmenu(): void { diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 00fd8712044dcf..c775a60c48accb 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -265,7 +265,6 @@ 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/chatContextActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts index 00c445ab3f3b9c..d7f76f6fbe0181 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts @@ -506,6 +506,8 @@ export class AttachContextAction extends Action2 { }, { when: ContextKeyExpr.and( ChatContextKeys.inQuickChat, + // Hide the attach-context button in the floating input window. + ChatContextKeys.inChatInputWindow.negate(), ContextKeyExpr.or( ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeys.agentSupportsAttachments 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 445931e5c8f344..d368226f1ee050 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts @@ -62,9 +62,14 @@ registerAction2(class extends Action2 { f1: false, icon: Codicon.close, menu: { - id: MenuId.ChatInputWindowSide, + // Render inside the input box's execute toolbar (next to submit) + // rather than the side toolbar, which sits outside the rounded box + // over the transparent window area. Gated to the floating window so + // it never appears in other chat inputs. + id: MenuId.ChatExecute, group: 'navigation', - order: 10 + order: 100, + when: ChatContextKeys.inChatInputWindow } }); } diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index d11cb7e424a112..1caaaea581658b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -12,7 +12,6 @@ import { InstantiationType, registerSingleton } from '../../../../../platform/in 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 { 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'; @@ -27,7 +26,7 @@ import { IChatModelReference, IChatService } from '../../common/chatService/chat import { ChatWidget } from '../widget/chatWidget.js'; 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'; +import { IChatInputWindowService, ChatInputWindowStorageKeys, 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; @@ -263,7 +262,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind filter: () => false, enableImplicitContext: false, defaultMode: ChatMode.Ask, - menus: { inputSideToolbar: MenuId.ChatInputWindowSide, telemetrySource: 'chatInputWindow' }, + menus: { 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. @@ -344,6 +343,8 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind const centeredX = Math.round(mainWindow.screenX + (mainWindow.outerWidth - auxiliaryWindow.window.outerWidth) / 2); const centeredY = Math.round(mainWindow.screenY + (mainWindow.outerHeight - contentHeight) / 2); auxiliaryWindow.window.moveTo(centeredX, centeredY); + // Place the caret in the input so it's ready to type into. + widget.focusInput(); })); this._windowDisposables.add(dom.addDisposableListener(auxiliaryWindow.window, 'resize', layout)); } @@ -388,13 +389,17 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } private _defaultBounds(): IRectangle { + // Match Quick Chat's width so the model-detail hover has room to sit + // beside the picker: golden-cut of the main window, capped like the + // quick input widget (MAX_WIDTH = 600). + const width = Math.round(Math.min(mainWindow.innerWidth * 0.62, 600)); // Center the omni bar within the main VS Code window. - const x = Math.round(mainWindow.screenX + (mainWindow.outerWidth - CHAT_INPUT_WINDOW_DEFAULT_WIDTH) / 2); + const x = Math.round(mainWindow.screenX + (mainWindow.outerWidth - width) / 2); const y = Math.round(mainWindow.screenY + (mainWindow.outerHeight - CHAT_INPUT_WINDOW_DEFAULT_HEIGHT) / 2); return { x, y, - width: CHAT_INPUT_WINDOW_DEFAULT_WIDTH, + width, height: CHAT_INPUT_WINDOW_DEFAULT_HEIGHT, }; } diff --git a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts index 7e858d75ff195c..a097a3bd6004e6 100644 --- a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts +++ b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts @@ -9,9 +9,8 @@ import { Event } from '../../../../base/common/event.js'; export const CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID = 'workbench.action.chat.toggleInputWindow'; /** - * Default dimensions for the floating chat input window. + * Default height for the floating chat input window. */ -export const CHAT_INPUT_WINDOW_DEFAULT_WIDTH = 520; export const CHAT_INPUT_WINDOW_DEFAULT_HEIGHT = 110; /** From 049850feefec8d88af4a8e198d25c5a02efd3a3a Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 30 Jul 2026 17:44:04 -0400 Subject: [PATCH 16/37] improvements --- .../chatInputWindow/chatInputWindowService.ts | 60 +++++++++++++++---- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index 1caaaea581658b..bd475c65dc76cb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { AnchorPosition } from '../../../../../base/common/layout.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { mainWindow } from '../../../../../base/browser/window.js'; @@ -329,22 +329,60 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind const layout = () => widget.layout(parent.offsetHeight, parent.offsetWidth); layout(); - this._windowDisposables.add(dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => { - const windowChromeHeight = auxiliaryWindow.window.outerHeight - auxiliaryWindow.window.innerHeight; - // Size the surface to the input box plus a symmetric padding ring on - // every side, so the box is centered and its rounded corners aren't - // clipped. + + // Fit the frameless window to the input box plus a symmetric padding ring + // on every side, so the box is centered and its rounded corners aren't + // clipped. Skip while a picker (model / routing disambiguation) has grown + // the window taller so we don't fight its manual resize. Only touch the OS + // window when the size actually changed: `resizeTo`/`moveTo` steal focus + // from the input (blanking the caret), so an idempotent no-op keeps the + // caret visible while the input height is stable. + let lastContentHeight: number | undefined; + const fitWindowToInput = () => { + if (this._preExpandHeight !== undefined || this._actionWidgetRestoreHeight !== undefined) { + return; + } + const win = this._window?.window; + if (!win || win !== auxiliaryWindow.window) { + return; + } const inputHeight = widget.input.inputContainerElement?.getBoundingClientRect().height; const contentHeight = inputHeight === undefined ? CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT : Math.ceil(inputHeight + 2 * CHAT_INPUT_WINDOW_SURFACE_PADDING); + if (contentHeight === lastContentHeight) { + return; + } + lastContentHeight = contentHeight; contentSurface.style.height = `${contentHeight}px`; - auxiliaryWindow.window.resizeTo(auxiliaryWindow.window.outerWidth, contentHeight + windowChromeHeight); - const centeredX = Math.round(mainWindow.screenX + (mainWindow.outerWidth - auxiliaryWindow.window.outerWidth) / 2); + const windowChromeHeight = win.outerHeight - win.innerHeight; + win.resizeTo(win.outerWidth, contentHeight + windowChromeHeight); + const centeredX = Math.round(mainWindow.screenX + (mainWindow.outerWidth - win.outerWidth) / 2); const centeredY = Math.round(mainWindow.screenY + (mainWindow.outerHeight - contentHeight) / 2); - auxiliaryWindow.window.moveTo(centeredX, centeredY); - // Place the caret in the input so it's ready to type into. - widget.focusInput(); + win.moveTo(centeredX, centeredY); + }; + + // Keep the window fitted as the input grows/shrinks (e.g. multi-line + // text), instead of relying on a single racy measurement that can catch + // the input before it collapses to its compact single-line height. + const inputContainer = widget.input.inputContainerElement; + if (inputContainer) { + const resizeObserver = new auxiliaryWindow.window.ResizeObserver(() => fitWindowToInput()); + resizeObserver.observe(inputContainer); + this._windowDisposables.add(toDisposable(() => resizeObserver.disconnect())); + } + + this._windowDisposables.add(dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => { + fitWindowToInput(); + // Focus the input only after the window has been positioned: the + // `moveTo`/`resizeTo` above blur the editor, so focusing in a + // follow-up frame (after the OS window is settled and keyed) is what + // makes the caret actually render. Focus the OS window first so + // macOS treats it as key and shows a live (not dimmed) caret. + this._windowDisposables.add(dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => { + auxiliaryWindow.window.focus(); + widget.focusInput(); + })); })); this._windowDisposables.add(dom.addDisposableListener(auxiliaryWindow.window, 'resize', layout)); } From 1d6e1241b449ca93009e666a1074292ec16c25d3 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 30 Jul 2026 17:44:46 -0400 Subject: [PATCH 17/37] improvement --- .../chatInputWindow/chatInputWindowService.ts | 12 +++++++++--- 1 file changed, 9 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 bd475c65dc76cb..2727ece7f4b640 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -377,13 +377,19 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind // Focus the input only after the window has been positioned: the // `moveTo`/`resizeTo` above blur the editor, so focusing in a // follow-up frame (after the OS window is settled and keyed) is what - // makes the caret actually render. Focus the OS window first so - // macOS treats it as key and shows a live (not dimmed) caret. + // makes the caret actually render. this._windowDisposables.add(dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => { - auxiliaryWindow.window.focus(); widget.focusInput(); })); })); + // Monaco's edit-context focus tracker only re-checks its focus state on + // DOM focus/blur events. When this frameless window is created it may not + // be the OS key window yet, so the initial `focusInput()` runs while + // `document.hasFocus()` is false and the tracker latches `_isFocused = + // false` — leaving the caret hidden even though the edit context holds DOM + // focus. Re-focus the input whenever the window becomes key so the tracker + // re-evaluates and renders a live caret. + this._windowDisposables.add(dom.addDisposableListener(auxiliaryWindow.window, 'focus', () => widget.focusInput())); this._windowDisposables.add(dom.addDisposableListener(auxiliaryWindow.window, 'resize', layout)); } From 12799cd6c04ce64199f72ce0bbbd0de8ca0fabb9 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 30 Jul 2026 17:54:52 -0400 Subject: [PATCH 18/37] Fix cursor not showing in omni-chat floating input The native edit-context focus tracker consulted the globally-active document via getActiveElement() instead of the DOM node's own document. In the frameless auxiliary chat-input window the edit context could hold DOM focus while the tracker latched _isFocused=false, hiding the caret. Track focus against the DOM node's owner document instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../native/nativeEditContextUtils.ts | 4 +- .../controller/nativeEditContextUtils.test.ts | 39 +++++++++++++++++++ .../chatInputWindow/chatInputWindowService.ts | 19 +++++---- 3 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 src/vs/editor/test/browser/controller/nativeEditContextUtils.test.ts diff --git a/src/vs/editor/browser/controller/editContext/native/nativeEditContextUtils.ts b/src/vs/editor/browser/controller/editContext/native/nativeEditContextUtils.ts index 86436a376ec727..22c563c291c4be 100644 --- a/src/vs/editor/browser/controller/editContext/native/nativeEditContextUtils.ts +++ b/src/vs/editor/browser/controller/editContext/native/nativeEditContextUtils.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { addDisposableListener, getActiveElement, getShadowRoot } from '../../../../../base/browser/dom.js'; +import { addDisposableListener, getShadowRoot } from '../../../../../base/browser/dom.js'; import { IDisposable, Disposable } from '../../../../../base/common/lifecycle.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -67,7 +67,7 @@ export class FocusTracker extends Disposable { public refreshFocusState(): void { const shadowRoot = getShadowRoot(this._domNode); - const activeElement = shadowRoot ? shadowRoot.activeElement : getActiveElement(); + const activeElement = shadowRoot ? shadowRoot.activeElement : this._domNode.ownerDocument.activeElement; const focused = this._domNode === activeElement; this._handleFocusedChanged(focused); } diff --git a/src/vs/editor/test/browser/controller/nativeEditContextUtils.test.ts b/src/vs/editor/test/browser/controller/nativeEditContextUtils.test.ts new file mode 100644 index 00000000000000..0f207a3d4df650 --- /dev/null +++ b/src/vs/editor/test/browser/controller/nativeEditContextUtils.test.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { toDisposable } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../platform/log/common/log.js'; +import { FocusTracker } from '../../../browser/controller/editContext/native/nativeEditContextUtils.js'; + +suite('NativeEditContextUtils', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('tracks focus in the DOM node owner document', () => { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + disposables.add(toDisposable(() => iframe.remove())); + + const target = iframe.contentDocument!.createElement('div'); + target.tabIndex = 0; + iframe.contentDocument!.body.appendChild(target); + + let focused = false; + const tracker = disposables.add(new FocusTracker(new NullLogService(), target, value => focused = value)); + target.focus(); + + assert.deepStrictEqual({ + activeElement: iframe.contentDocument!.activeElement === target, + focused, + trackerFocused: tracker.isFocused, + }, { + activeElement: true, + focused: true, + trackerFocused: true, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index 2727ece7f4b640..3e956d06e851e9 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -364,10 +364,15 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind // Keep the window fitted as the input grows/shrinks (e.g. multi-line // text), instead of relying on a single racy measurement that can catch - // the input before it collapses to its compact single-line height. + // the input before it collapses to its compact single-line height. Defer + // the fit out of the observer callback so the `resizeTo`/`moveTo` don't + // reenter the layout pass that triggered the notification. const inputContainer = widget.input.inputContainerElement; - if (inputContainer) { - const resizeObserver = new auxiliaryWindow.window.ResizeObserver(() => fitWindowToInput()); + if (inputContainer && typeof auxiliaryWindow.window.ResizeObserver === 'function') { + const scheduledFit = this._windowDisposables.add(new MutableDisposable()); + const resizeObserver = new auxiliaryWindow.window.ResizeObserver(() => { + scheduledFit.value = dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => fitWindowToInput()); + }); resizeObserver.observe(inputContainer); this._windowDisposables.add(toDisposable(() => resizeObserver.disconnect())); } @@ -382,13 +387,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind widget.focusInput(); })); })); - // Monaco's edit-context focus tracker only re-checks its focus state on - // DOM focus/blur events. When this frameless window is created it may not - // be the OS key window yet, so the initial `focusInput()` runs while - // `document.hasFocus()` is false and the tracker latches `_isFocused = - // false` — leaving the caret hidden even though the edit context holds DOM - // focus. Re-focus the input whenever the window becomes key so the tracker - // re-evaluates and renders a live caret. + // Refresh editor focus when the auxiliary window becomes active. this._windowDisposables.add(dom.addDisposableListener(auxiliaryWindow.window, 'focus', () => widget.focusInput())); this._windowDisposables.add(dom.addDisposableListener(auxiliaryWindow.window, 'resize', layout)); } From aa883784f39e88ce5a13936584e1047874ed316e Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 30 Jul 2026 18:02:44 -0400 Subject: [PATCH 19/37] Make the omni-chat input window draggable Make the visible input-box chrome the drag surface for the frameless floating chat input window instead of relying on the thin transparent padding ring. The .interactive-session wrapper is now marked -webkit-app-region: drag, and the interactive interior (editor, toolbars, attachments) is carved back out as no-drag so those controls stay clickable/typable. Exposes ChatInputPart.interactiveInputElements so the window host can do this without querying the DOM. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chatInputWindow/chatInputWindowService.ts | 29 ++++++++++++++++--- .../browser/widget/input/chatInputPart.ts | 13 +++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index 3e956d06e851e9..9190f0909aa76d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -228,12 +228,15 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private _renderChatWidget(auxiliaryWindow: IAuxiliaryWindow, contentSurface: HTMLElement): void { // The glow CSS keys off `.monaco-workbench .interactive-session - // .chat-input-container` — the aux container already tracks the + // .chat-input-container` - the aux container already tracks the // `monaco-workbench` class, so we only need the `.interactive-session` - // wrapper here. Mark it no-drag so the visible input box stays fully - // interactive; only the transparent padding ring around it drags. + // wrapper here. Make the whole wrapper draggable so the visible input + // box itself moves the frameless window; the genuinely interactive + // regions (editor, toolbars) are carved back out as no-drag below. A + // transparent padding ring alone is too thin (and too invisible) to be + // a reliable drag target on a transparent window. const parent = dom.append(contentSurface, dom.$('.interactive-session')); - (parent.style as CSSStyleDeclaration & { '-webkit-app-region': string })['-webkit-app-region'] = 'no-drag'; + (parent.style as CSSStyleDeclaration & { '-webkit-app-region': string })['-webkit-app-region'] = 'drag'; parent.style.flex = '1 1 auto'; parent.style.minHeight = '0'; parent.style.width = '100%'; @@ -281,6 +284,13 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind widget.render(parent); widget.setVisible(true); + // The wrapper is draggable (see above). `-webkit-app-region` inherits, + // so marking the interactive containers no-drag keeps the editor and its + // toolbars fully usable while their surrounding input-box chrome still + // drags the window. These containers exist synchronously after render; + // buttons/suggestions added later inherit no-drag from them. + this._markInteractiveRegionsNoDrag(widget.input.interactiveInputElements); + const modelRef = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { disableBackgroundKeepAlive: true, debugOwner: 'ChatInputWindow' }); this._modelRef = modelRef; widget.setModel(modelRef.object); @@ -392,6 +402,17 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind this._windowDisposables.add(dom.addDisposableListener(auxiliaryWindow.window, 'resize', layout)); } + /** + * Marks the interactive parts of the chat input (editor, toolbars, + * attachments) as non-draggable so the surrounding input-box chrome can drag + * the frameless window while these controls stay clickable/typable. + */ + private _markInteractiveRegionsNoDrag(elements: HTMLElement[]): void { + for (const element of elements) { + (element.style as CSSStyleDeclaration & { '-webkit-app-region': string })['-webkit-app-region'] = 'no-drag'; + } + } + private async _layoutForModelPicker(auxiliaryWindow: IAuxiliaryWindow, visible: boolean): Promise { const win = auxiliaryWindow.window; if (visible) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index c9be394d2bc2ae..da307fdee7ce97 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -433,6 +433,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatGoalBannerContainer!: HTMLElement; private persistentContentContainer!: HTMLElement; private inputContainer!: HTMLElement; + private editorContainer!: HTMLElement; + private toolbarsContainer!: HTMLElement; private readonly _notificationWidget = this._register(new MutableDisposable()); private readonly _goalBannerWidget = this._register(new MutableDisposable()); private readonly _onDidDismissGoalBanner = this._register(new Emitter()); @@ -447,6 +449,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this.inputContainer; } + /** + * The interactive interior of the input (editor, toolbars, attachments). + * Hosts embedding the input in a draggable frameless window use these to + * exclude the controls from the window drag region. + */ + get interactiveInputElements(): HTMLElement[] { + return [this.editorContainer, this.toolbarsContainer, this.attachmentsContainer].filter((element): element is HTMLElement => !!element); + } + get persistentContentContainerElement(): HTMLElement { return this.persistentContentContainer; } @@ -2967,9 +2978,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const inputContainer = elements.inputContainer; // The chat editor, attachments, and toolbars this.inputContainer = inputContainer; const editorContainer = elements.editorContainer; + this.editorContainer = editorContainer; this.attachmentsContainer = elements.attachmentsContainer; this.attachedContextContainer = elements.attachedContextContainer; const toolbarsContainer = elements.inputToolbars; + this.toolbarsContainer = toolbarsContainer; this.secondaryToolbarContainer = elements.secondaryToolbar; if (this.options.renderStyle === 'compact') { this.secondaryToolbarContainer.style.display = 'none'; From b2979eed5968f2c7f19e443125d86640e7894ca2 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 30 Jul 2026 18:09:42 -0400 Subject: [PATCH 20/37] Refresh focus synchronously in FocusTracker regression test Use the tracker's own focus() so focus state is refreshed synchronously, avoiding reliance on the DOM focus event which is unreliable when the host window is not active (the frameless aux chat-input window case). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/browser/controller/nativeEditContextUtils.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/vs/editor/test/browser/controller/nativeEditContextUtils.test.ts b/src/vs/editor/test/browser/controller/nativeEditContextUtils.test.ts index 0f207a3d4df650..60848e04e13292 100644 --- a/src/vs/editor/test/browser/controller/nativeEditContextUtils.test.ts +++ b/src/vs/editor/test/browser/controller/nativeEditContextUtils.test.ts @@ -24,7 +24,11 @@ suite('NativeEditContextUtils', () => { let focused = false; const tracker = disposables.add(new FocusTracker(new NullLogService(), target, value => focused = value)); - target.focus(); + // Use the tracker's own `focus()` so the focus state is refreshed + // synchronously. Relying on the DOM `focus` event is unreliable when the + // host window is not the active window (as with the frameless auxiliary + // chat-input window), which is exactly the case this fix targets. + tracker.focus(); assert.deepStrictEqual({ activeElement: iframe.contentDocument!.activeElement === target, From f8406d1dcc6ed11ff9cf0c4a8ba33aa381aea0bb Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 30 Jul 2026 18:38:23 -0400 Subject: [PATCH 21/37] Make omni-chat input window draggable via command-center pattern Adopt the same approach the command center uses in the title bar: a dedicated, full-size .chat-input-window-drag-region layer sits behind the input and is marked -webkit-app-region: drag in CSS, while the interactive controls (editor, toolbars, attachments) render on top and are marked no-drag. This gives a large, reliable drag surface (the input-box chrome) instead of relying on a thin transparent ring or fragile app-region inheritance. Also center the window on the window that invoked it (the active window) rather than always the main window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chatInputWindow/chatInputWindowService.ts | 64 ++++++++----------- .../chatInputWindow/media/chatInputWindow.css | 40 ++++++++++++ .../browser/widget/input/chatInputPart.ts | 13 ---- 3 files changed, 66 insertions(+), 51 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 9190f0909aa76d..c5f73958e0fbfd 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import './media/chatInputWindow.css'; import * as dom from '../../../../../base/browser/dom.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { AnchorPosition } from '../../../../../base/common/layout.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; -import { mainWindow } from '../../../../../base/browser/window.js'; +import { CodeWindow, 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'; @@ -66,6 +67,8 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind /** Window height (outer) captured before growing to fit the routing picker; restored on close. */ private _preExpandHeight: number | undefined; private _actionWidgetRestoreHeight: number | undefined; + /** The window that invoked the input window; used to center it on that window. */ + private _invokingWindow: CodeWindow = mainWindow; get isOpen(): boolean { return !!this._window; @@ -120,6 +123,9 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } private async _doOpenWindow(): Promise { + // Capture the window that invoked us (before the aux window steals focus) + // so the input window is centered on it rather than always the main one. + this._invokingWindow = dom.getActiveWindow(); const bounds = this._defaultBounds(); const auxiliaryWindow = await this.auxiliaryWindowService.open({ @@ -157,11 +163,11 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind }; // The content surface is an invisible host: it stays transparent and adds - // a small symmetric padding ring around the input. That ring both centers - // the input box and — because a frameless window can only be moved through - // a `-webkit-app-region: drag` region — provides the draggable area. The - // input box itself is marked no-drag inside the widget so it stays - // interactive, leaving only the surrounding ring grabbable. + // a small symmetric padding ring around the input. Dragging is provided + // the same way the command center is in the title bar: a dedicated, + // full-size `.chat-input-window-drag-region` layer sits behind the input + // (see chatInputWindow.css) and marks itself `-webkit-app-region: drag`, + // while the interactive controls are painted on top and marked no-drag. const contentSurface = dom.append(auxiliaryWindow.container, dom.$('.chat-input-window-content')); contentSurface.style.boxSizing = 'border-box'; contentSurface.style.width = '100%'; @@ -174,7 +180,8 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind contentSurface.style.cursor = 'grab'; contentSurface.style.display = 'flex'; contentSurface.style.flexDirection = 'column'; - (contentSurface.style as CSSStyleDeclaration & { '-webkit-app-region': string })['-webkit-app-region'] = 'drag'; + // Full-size drag layer behind the input; the widget content sits on top. + dom.append(contentSurface, dom.$('.chat-input-window-drag-region')); applyThemeColors(); this._windowDisposables.add(this.themeService.onDidColorThemeChange(() => applyThemeColors())); @@ -230,13 +237,10 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind // 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. Make the whole wrapper draggable so the visible input - // box itself moves the frameless window; the genuinely interactive - // regions (editor, toolbars) are carved back out as no-drag below. A - // transparent padding ring alone is too thin (and too invisible) to be - // a reliable drag target on a transparent window. + // wrapper here. It renders above the drag layer (see chatInputWindow.css) + // so its controls stay interactive; the input-box chrome around them + // falls through to the drag layer and moves the window. const parent = dom.append(contentSurface, dom.$('.interactive-session')); - (parent.style as CSSStyleDeclaration & { '-webkit-app-region': string })['-webkit-app-region'] = 'drag'; parent.style.flex = '1 1 auto'; parent.style.minHeight = '0'; parent.style.width = '100%'; @@ -284,13 +288,6 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind widget.render(parent); widget.setVisible(true); - // The wrapper is draggable (see above). `-webkit-app-region` inherits, - // so marking the interactive containers no-drag keeps the editor and its - // toolbars fully usable while their surrounding input-box chrome still - // drags the window. These containers exist synchronously after render; - // buttons/suggestions added later inherit no-drag from them. - this._markInteractiveRegionsNoDrag(widget.input.interactiveInputElements); - const modelRef = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { disableBackgroundKeepAlive: true, debugOwner: 'ChatInputWindow' }); this._modelRef = modelRef; widget.setModel(modelRef.object); @@ -367,8 +364,9 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind contentSurface.style.height = `${contentHeight}px`; const windowChromeHeight = win.outerHeight - win.innerHeight; win.resizeTo(win.outerWidth, contentHeight + windowChromeHeight); - const centeredX = Math.round(mainWindow.screenX + (mainWindow.outerWidth - win.outerWidth) / 2); - const centeredY = Math.round(mainWindow.screenY + (mainWindow.outerHeight - contentHeight) / 2); + const invokingWindow = this._invokingWindow; + const centeredX = Math.round(invokingWindow.screenX + (invokingWindow.outerWidth - win.outerWidth) / 2); + const centeredY = Math.round(invokingWindow.screenY + (invokingWindow.outerHeight - contentHeight) / 2); win.moveTo(centeredX, centeredY); }; @@ -402,17 +400,6 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind this._windowDisposables.add(dom.addDisposableListener(auxiliaryWindow.window, 'resize', layout)); } - /** - * Marks the interactive parts of the chat input (editor, toolbars, - * attachments) as non-draggable so the surrounding input-box chrome can drag - * the frameless window while these controls stay clickable/typable. - */ - private _markInteractiveRegionsNoDrag(elements: HTMLElement[]): void { - for (const element of elements) { - (element.style as CSSStyleDeclaration & { '-webkit-app-region': string })['-webkit-app-region'] = 'no-drag'; - } - } - private async _layoutForModelPicker(auxiliaryWindow: IAuxiliaryWindow, visible: boolean): Promise { const win = auxiliaryWindow.window; if (visible) { @@ -453,13 +440,14 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } private _defaultBounds(): IRectangle { + const invokingWindow = this._invokingWindow; // Match Quick Chat's width so the model-detail hover has room to sit - // beside the picker: golden-cut of the main window, capped like the + // beside the picker: golden-cut of the invoking window, capped like the // quick input widget (MAX_WIDTH = 600). - const width = Math.round(Math.min(mainWindow.innerWidth * 0.62, 600)); - // Center the omni bar within the main VS Code window. - const x = Math.round(mainWindow.screenX + (mainWindow.outerWidth - width) / 2); - const y = Math.round(mainWindow.screenY + (mainWindow.outerHeight - CHAT_INPUT_WINDOW_DEFAULT_HEIGHT) / 2); + const width = Math.round(Math.min(invokingWindow.innerWidth * 0.62, 600)); + // Center the omni bar within the window that invoked it. + const x = Math.round(invokingWindow.screenX + (invokingWindow.outerWidth - width) / 2); + const y = Math.round(invokingWindow.screenY + (invokingWindow.outerHeight - CHAT_INPUT_WINDOW_DEFAULT_HEIGHT) / 2); return { x, y, 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..d705e6db4ebaaa --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * Dragging the frameless chat-input window mirrors how the command center is + * made draggable in the title bar: a dedicated, full-size drag region sits + * behind the content, and the genuinely interactive controls are painted on + * top (higher stacking) and marked no-drag. This gives a large, reliable drag + * surface (the input box chrome) without stealing pointer events from the + * editor or toolbars. + */ + +.chat-input-window-content { + position: relative; +} + +/* The full-size drag layer, behind everything else. */ +.chat-input-window-content > .chat-input-window-drag-region { + position: absolute; + inset: 0; + z-index: 0; + -webkit-app-region: drag; +} + +/* Keep the widget above the drag layer so its controls stay interactive. */ +.chat-input-window-content > .interactive-session { + position: relative; + z-index: 1; +} + +/* Carve the interactive controls out of the drag region. */ +.chat-input-window-content .chat-editor-container, +.chat-input-window-content .chat-input-toolbars, +.chat-input-window-content .chat-secondary-toolbar, +.chat-input-window-content .chat-attachments-container, +.chat-input-window-content .monaco-toolbar { + -webkit-app-region: no-drag; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index da307fdee7ce97..c9be394d2bc2ae 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -433,8 +433,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatGoalBannerContainer!: HTMLElement; private persistentContentContainer!: HTMLElement; private inputContainer!: HTMLElement; - private editorContainer!: HTMLElement; - private toolbarsContainer!: HTMLElement; private readonly _notificationWidget = this._register(new MutableDisposable()); private readonly _goalBannerWidget = this._register(new MutableDisposable()); private readonly _onDidDismissGoalBanner = this._register(new Emitter()); @@ -449,15 +447,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this.inputContainer; } - /** - * The interactive interior of the input (editor, toolbars, attachments). - * Hosts embedding the input in a draggable frameless window use these to - * exclude the controls from the window drag region. - */ - get interactiveInputElements(): HTMLElement[] { - return [this.editorContainer, this.toolbarsContainer, this.attachmentsContainer].filter((element): element is HTMLElement => !!element); - } - get persistentContentContainerElement(): HTMLElement { return this.persistentContentContainer; } @@ -2978,11 +2967,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const inputContainer = elements.inputContainer; // The chat editor, attachments, and toolbars this.inputContainer = inputContainer; const editorContainer = elements.editorContainer; - this.editorContainer = editorContainer; this.attachmentsContainer = elements.attachmentsContainer; this.attachedContextContainer = elements.attachedContextContainer; const toolbarsContainer = elements.inputToolbars; - this.toolbarsContainer = toolbarsContainer; this.secondaryToolbarContainer = elements.secondaryToolbar; if (this.options.renderStyle === 'compact') { this.secondaryToolbarContainer.style.display = 'none'; From 3c7a990bdea700df52965ff7f5f7df561495965e Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 15:34:09 -0400 Subject: [PATCH 22/37] Polish omni chat routing surface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11adb7d-72c1-4e64-ba1e-f798dd9bf490 --- .../lib/stylelint/vscode-known-variables.json | 5 +- .../chatInputWindow.contribution.ts | 10 - .../chatInputWindow/chatInputWindowService.ts | 181 ++++++------ .../chatInputWindow/media/chatInputWindow.css | 177 +++++++++--- .../chatSessionRoutingController.ts | 264 ++++++++++-------- .../media/chatSessionRouting.css | 155 +++++++++- .../browser/auxiliaryWindowService.ts | 7 + .../auxiliaryWindowService.ts | 5 + 8 files changed, 562 insertions(+), 242 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 694aaf180693e3..73cc428c9c4696 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1111,7 +1111,10 @@ "--vscode-inline-chat-affordance-height", "--collapse-from-width", "--slide-from-x", - "--slide-from-y" + "--slide-from-y", + "--omni-icon-column", + "--omni-rail", + "--omni-row-gap" ], "sizes": [ "--segmented-icon-toggle-cell-radius", 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 d368226f1ee050..7fcec1f26649a1 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts @@ -61,16 +61,6 @@ registerAction2(class extends Action2 { category: Categories.View, f1: false, icon: Codicon.close, - menu: { - // Render inside the input box's execute toolbar (next to submit) - // rather than the side toolbar, which sits outside the rounded box - // over the transparent window area. Gated to the floating window so - // it never appears in other chat inputs. - id: MenuId.ChatExecute, - group: 'navigation', - order: 100, - when: ChatContextKeys.inChatInputWindow - } }); } run(accessor: ServicesAccessor): void { diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index c5f73958e0fbfd..6900cfa479e25b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -5,8 +5,11 @@ import './media/chatInputWindow.css'; import * as dom from '../../../../../base/browser/dom.js'; +import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { AnchorPosition } from '../../../../../base/common/layout.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { CodeWindow, mainWindow } from '../../../../../base/browser/window.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; @@ -18,7 +21,7 @@ import { IAuxiliaryWindowService, IAuxiliaryWindow } from '../../../../services/ 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 } from '../../../../../platform/theme/common/colors/inputColors.js'; +import { inputBackground, inputBorder } from '../../../../../platform/theme/common/colors/inputColors.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { localize } from '../../../../../nls.js'; import { ChatAgentLocation } from '../../common/constants.js'; @@ -29,16 +32,8 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatSessionRoutingController, IChatSessionRoutingHost } from '../sessionRouter/chatSessionRoutingController.js'; import { IChatInputWindowService, ChatInputWindowStorageKeys, 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; const CHAT_INPUT_WINDOW_MODEL_PICKER_HEIGHT = 420; -const CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT = 48; -/** Symmetric transparent padding around the input box; also the draggable ring. */ -const CHAT_INPUT_WINDOW_SURFACE_PADDING = 8; +const CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT = 44; /** * Hosts a frameless, always-on-top auxiliary window containing the full chat @@ -58,14 +53,12 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private readonly _windowDisposables = this._register(new DisposableStore()); private readonly _ownershipChannel: BroadcastChannel; private _modelRef: IChatModelReference | undefined; - /** Parent element hosting the input widget; the routing badge is inserted just before it. */ - private _widgetParent: HTMLElement | undefined; + /** The single input row; routing results are inserted immediately after it. */ + private _row: HTMLElement | undefined; /** 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; - /** Window height (outer) captured before growing to fit the routing picker; restored on close. */ - private _preExpandHeight: number | undefined; private _actionWidgetRestoreHeight: number | undefined; /** The window that invoked the input window; used to center it on that window. */ private _invokingWindow: CodeWindow = mainWindow; @@ -149,39 +142,47 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind : localize('chatInputWindow.title', "Chat Input"); auxiliaryWindow.container.style.overflow = 'hidden'; - auxiliaryWindow.container.style.backgroundColor = 'transparent'; - auxiliaryWindow.container.style.border = 'none'; + auxiliaryWindow.container.classList.add('chat-input-window'); + auxiliaryWindow.window.document.body.classList.add('chat-input-window-body'); auxiliaryWindow.window.document.body.style.setProperty('margin', '0', 'important'); this._windowDisposables.clear(); - // Keep the window body itself fully transparent; the only visible chrome - // is the chat input box, which brings its own background, border and - // rounded corners. Re-assert transparency on theme changes. const applyThemeColors = () => { + const theme = this.themeService.getColorTheme(); + const surface = theme.getColor(inputBackground)?.toString() ?? '#3c3c3c'; + const border = theme.getColor(inputBorder)?.toString() ?? 'transparent'; auxiliaryWindow.window.document.body.style.setProperty('background-color', 'transparent', 'important'); + auxiliaryWindow.container.style.backgroundColor = surface; + auxiliaryWindow.container.style.border = `1px solid ${border}`; + auxiliaryWindow.container.style.boxSizing = 'border-box'; }; - // The content surface is an invisible host: it stays transparent and adds - // a small symmetric padding ring around the input. Dragging is provided - // the same way the command center is in the title bar: a dedicated, - // full-size `.chat-input-window-drag-region` layer sits behind the input - // (see chatInputWindow.css) and marks itself `-webkit-app-region: drag`, - // while the interactive controls are painted on top and marked no-drag. - const contentSurface = dom.append(auxiliaryWindow.container, dom.$('.chat-input-window-content')); + auxiliaryWindow.container.style.display = 'flex'; + auxiliaryWindow.container.style.flexDirection = 'column'; + + const row = dom.append(auxiliaryWindow.container, dom.$('.chat-input-window-row')); + this._row = row; + const lead = dom.append(row, dom.$('.chat-input-window-lead', { + 'aria-hidden': 'true', + title: localize('chatInputWindow.drag', "Drag to move"), + })); + lead.style.setProperty('-webkit-app-region', 'drag'); + const dragGlyph = dom.append(lead, dom.$('span.chat-input-window-drag-glyph')); + dom.append(dragGlyph, dom.$('span')); + dom.append(dragGlyph, dom.$('span')); + dom.append(dragGlyph, dom.$('span')); + + const contentSurface = dom.append(row, dom.$('.chat-input-window-content')); contentSurface.style.boxSizing = 'border-box'; - contentSurface.style.width = '100%'; + contentSurface.style.flex = '1 1 auto'; + contentSurface.style.minWidth = '0'; contentSurface.style.height = `${CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT}px`; - contentSurface.style.flex = '0 0 auto'; contentSurface.style.overflow = 'visible'; contentSurface.style.backgroundColor = 'transparent'; contentSurface.style.border = 'none'; - contentSurface.style.padding = `${CHAT_INPUT_WINDOW_SURFACE_PADDING}px`; - contentSurface.style.cursor = 'grab'; contentSurface.style.display = 'flex'; contentSurface.style.flexDirection = 'column'; - // Full-size drag layer behind the input; the widget content sits on top. - dom.append(contentSurface, dom.$('.chat-input-window-drag-region')); applyThemeColors(); this._windowDisposables.add(this.themeService.onDidColorThemeChange(() => applyThemeColors())); @@ -191,6 +192,21 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind // seam) and routed to the best-matching existing session. this._renderChatWidget(auxiliaryWindow, contentSurface); + const trail = dom.append(row, dom.$('.chat-input-window-trail')); + const close = dom.append(trail, dom.$('a.chat-input-window-close', { + role: 'button', + tabindex: '0', + 'aria-label': localize('chatInputWindow.close.label', "Close"), + })); + close.appendChild(renderIcon(Codicon.close)); + this._windowDisposables.add(dom.addDisposableListener(close, dom.EventType.CLICK, () => this.closeWindow())); + this._windowDisposables.add(dom.addStandardDisposableListener(close, dom.EventType.KEY_DOWN, event => { + if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { + event.preventDefault(); + this.closeWindow(); + } + })); + // 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)(() => { @@ -244,7 +260,6 @@ 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 @@ -292,44 +307,36 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind this._modelRef = modelRef; widget.setModel(modelRef.object); + let fitWindowToInput = () => { }; + // 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. + // panel below 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._widgetParent?.parentElement; - if (container && this._widgetParent) { - 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) { + const container = this._window?.container; + const row = this._row; + if (!container || !row) { return; } - try { - if (visible) { - auxiliaryWindow.container.style.height = '100%'; - 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)); - win.dispatchEvent(new win.Event('resize')); - } else if (this._preExpandHeight !== undefined) { - win.resizeTo(win.outerWidth, this._preExpandHeight); - win.dispatchEvent(new win.Event('resize')); - this._preExpandHeight = undefined; + row.after(badge); + fitWindowToInput(); + const resizeObserver = new auxiliaryWindow.window.ResizeObserver(() => fitWindowToInput()); + resizeObserver.observe(badge); + const observer = new auxiliaryWindow.window.MutationObserver(() => { + if (!badge.isConnected) { + observer.disconnect(); + resizeObserver.disconnect(); + fitWindowToInput(); } - } catch { /* resize may not be supported */ } + }); + observer.observe(container, { childList: true }); + this._windowDisposables.add(toDisposable(() => { + observer.disconnect(); + resizeObserver.disconnect(); + })); }, }; this._routingController = this._windowDisposables.add(this.instantiationService.createInstance(ChatSessionRoutingController, host, 'chatInputWindow')); @@ -337,16 +344,14 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind const layout = () => widget.layout(parent.offsetHeight, parent.offsetWidth); layout(); - // Fit the frameless window to the input box plus a symmetric padding ring - // on every side, so the box is centered and its rounded corners aren't - // clipped. Skip while a picker (model / routing disambiguation) has grown - // the window taller so we don't fight its manual resize. Only touch the OS - // window when the size actually changed: `resizeTo`/`moveTo` steal focus - // from the input (blanking the caret), so an idempotent no-op keeps the - // caret visible while the input height is stable. + // Fit the frameless window to the input row and any routing panel below + // it. Skip while the model picker has grown the window so we do not fight + // its manual resize. Only touch the OS + // window when the size actually changed. let lastContentHeight: number | undefined; - const fitWindowToInput = () => { - if (this._preExpandHeight !== undefined || this._actionWidgetRestoreHeight !== undefined) { + let didInitialPosition = false; + fitWindowToInput = () => { + if (this._actionWidgetRestoreHeight !== undefined) { return; } const win = this._window?.window; @@ -354,20 +359,27 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind return; } const inputHeight = widget.input.inputContainerElement?.getBoundingClientRect().height; - const contentHeight = inputHeight === undefined + const rowHeight = inputHeight === undefined ? CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT - : Math.ceil(inputHeight + 2 * CHAT_INPUT_WINDOW_SURFACE_PADDING); + : Math.max(CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT, Math.ceil(inputHeight)); + contentSurface.style.height = `${rowHeight}px`; + const extraHeight = Array.from(auxiliaryWindow.container.children) + .filter(child => child !== this._row) + .reduce((height, child) => height + (child as HTMLElement).offsetHeight, 0); + const contentHeight = rowHeight + extraHeight + 2; if (contentHeight === lastContentHeight) { return; } lastContentHeight = contentHeight; - contentSurface.style.height = `${contentHeight}px`; - const windowChromeHeight = win.outerHeight - win.innerHeight; - win.resizeTo(win.outerWidth, contentHeight + windowChromeHeight); - const invokingWindow = this._invokingWindow; - const centeredX = Math.round(invokingWindow.screenX + (invokingWindow.outerWidth - win.outerWidth) / 2); - const centeredY = Math.round(invokingWindow.screenY + (invokingWindow.outerHeight - contentHeight) / 2); - win.moveTo(centeredX, centeredY); + let x = win.screenX; + let y = win.screenY; + if (!didInitialPosition) { + didInitialPosition = true; + const invokingWindow = this._invokingWindow; + x = Math.round(invokingWindow.screenX + (invokingWindow.outerWidth - win.outerWidth) / 2); + y = Math.round(invokingWindow.screenY + (invokingWindow.outerHeight - contentHeight) / 2); + } + void auxiliaryWindow.setBounds({ x, y, width: win.outerWidth, height: contentHeight }); }; // Keep the window fitted as the input grows/shrinks (e.g. multi-line @@ -410,13 +422,13 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind this._actionWidgetRestoreHeight = win.outerHeight; const desiredHeight = Math.max(win.outerHeight, CHAT_INPUT_WINDOW_MODEL_PICKER_HEIGHT); const windowChromeHeight = win.outerHeight - win.innerHeight; - win.resizeTo(win.outerWidth, desiredHeight); + await auxiliaryWindow.setBounds({ x: win.screenX, y: win.screenY, width: win.outerWidth, height: desiredHeight }); await this._waitForWindowHeight(auxiliaryWindow, desiredHeight - windowChromeHeight); win.focus(); } else if (this._actionWidgetRestoreHeight !== undefined) { const restoreHeight = this._actionWidgetRestoreHeight; this._actionWidgetRestoreHeight = undefined; - win.resizeTo(win.outerWidth, restoreHeight); + await auxiliaryWindow.setBounds({ x: win.screenX, y: win.screenY, width: win.outerWidth, height: restoreHeight }); win.dispatchEvent(new win.Event('resize')); } } @@ -432,8 +444,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private _disposeWidget(): void { this._routingController = undefined; - this._widgetParent = undefined; - this._preExpandHeight = undefined; + this._row = undefined; this._actionWidgetRestoreHeight = undefined; this._modelRef?.dispose(); this._modelRef = undefined; diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css b/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css index d705e6db4ebaaa..87ffd9f8d15848 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css @@ -3,38 +3,149 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* - * Dragging the frameless chat-input window mirrors how the command center is - * made draggable in the title bar: a dedicated, full-size drag region sits - * behind the content, and the genuinely interactive controls are painted on - * top (higher stacking) and marked no-drag. This gives a large, reliable drag - * surface (the input box chrome) without stealing pointer events from the - * editor or toolbars. - */ - -.chat-input-window-content { - position: relative; -} - -/* The full-size drag layer, behind everything else. */ -.chat-input-window-content > .chat-input-window-drag-region { - position: absolute; - inset: 0; - z-index: 0; - -webkit-app-region: drag; -} - -/* Keep the widget above the drag layer so its controls stay interactive. */ -.chat-input-window-content > .interactive-session { - position: relative; - z-index: 1; -} - -/* Carve the interactive controls out of the drag region. */ -.chat-input-window-content .chat-editor-container, -.chat-input-window-content .chat-input-toolbars, -.chat-input-window-content .chat-secondary-toolbar, -.chat-input-window-content .chat-attachments-container, -.chat-input-window-content .monaco-toolbar { +.chat-input-window { + --omni-rail: 20px; + --omni-icon-column: 16px; + --omni-row-gap: 8px; + + border-radius: var(--vscode-cornerRadius-xLarge); + overflow: hidden; +} + +.chat-input-window-body { + background: transparent !important; + overflow: hidden; +} + +.chat-input-window .chat-input-window-row { + display: flex; + align-items: center; + flex: 0 0 auto; + min-height: 44px; + padding: 0 8px; +} + +.chat-input-window .chat-input-window-lead { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 24px; + align-self: stretch; + color: var(--vscode-icon-foreground); + opacity: .45; + cursor: grab; +} + +.chat-input-window .chat-input-window-lead:active { + cursor: grabbing; +} + +.chat-input-window .chat-input-window-drag-glyph { + display: flex; + flex-direction: column; + gap: 2px; + width: 12px; +} + +.chat-input-window .chat-input-window-drag-glyph > span { + display: block; + height: 1px; + border-radius: var(--vscode-cornerRadius-circle); + background: currentColor; +} + +.chat-input-window .chat-input-window-trail { + display: flex; + align-items: center; + flex: 0 0 auto; + padding-left: 8px; +} + +.chat-input-window .chat-input-window-close { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: var(--vscode-cornerRadius-medium); + color: var(--vscode-icon-foreground); + opacity: .7; + cursor: pointer; -webkit-app-region: no-drag; } + +.chat-input-window .chat-input-window-close:hover { + opacity: 1; + background-color: var(--vscode-toolbar-hoverBackground); +} + +.chat-input-window .chat-input-window-close:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + opacity: 1; +} + +.chat-input-window .chat-input-window-close .codicon[class*='codicon-'] { + font-size: var(--vscode-codiconFontSize-compact); +} + +.chat-input-window .chat-side-toolbar { + display: none; +} + +.chat-input-window .chat-input-toolbars .chat-input-picker-item, +.chat-input-window .chat-input-toolbars .action-item > .codicon-add { + display: none; +} + +.chat-input-window .chat-input-container:not(.working) { + border-color: transparent !important; + background: transparent !important; +} + +.chat-input-window .chat-input-window-content, +.chat-input-window .chat-input-window-content > .interactive-session, +.chat-input-window .interactive-input-part, +.chat-input-window .interactive-input-and-edit-session, +.chat-input-window .interactive-input-and-side-toolbar, +.chat-input-window .chat-input-container, +.chat-input-window .chat-editor-container { + min-width: 0; + max-width: 100%; + box-sizing: border-box; +} + +.chat-input-window .interactive-input-part, +.chat-input-window .interactive-input-and-edit-session, +.chat-input-window .interactive-input-and-side-toolbar, +.chat-input-window .chat-input-container { + width: 100%; +} + +.chat-input-window .chat-editor-container { + flex: 1 1 auto; +} + +.chat-input-window .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item.chat-submit-button > .action-label { + background: none !important; + color: var(--vscode-icon-foreground) !important; + opacity: .4; + transition: opacity 120ms ease, transform 120ms cubic-bezier(.2, .8, .2, 1); +} + +.chat-input-window .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item.chat-submit-button:not(.disabled) > .action-label { + background: none !important; + color: var(--vscode-foreground) !important; + opacity: 1; + font-weight: 700; + transform: scale(1.08); +} + +.chat-input-window .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item.chat-submit-button:not(.disabled) > .action-label:hover { + background: var(--vscode-toolbar-hoverBackground) !important; +} + +.chat-input-window .chat-routing-badge { + background-color: transparent; + border-top: 1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, .25)); +} diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts index 70b02df4bc34bb..58d889490ab8de 100644 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts @@ -4,14 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; +import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.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'; @@ -121,27 +123,20 @@ export interface IChatSessionRoutingHost { /** 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. + * Insert the advisory badge into the host DOM near 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; - /** - * 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; } /** * 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. + * submitted utterance against existing agent sessions, resolves a pending target + * (best match above threshold, else a new session), then shows a ranked panel + * that counts down and auto-sends. The user can change or fan out the selection, + * abort, or keep typing to cancel before it fires. The last routed session is + * remembered to bias the next turn. */ export class ChatSessionRoutingController extends Disposable { @@ -159,7 +154,6 @@ export class ChatSessionRoutingController extends Disposable { @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @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, @@ -370,52 +364,6 @@ export class ChatSessionRoutingController extends Disposable { return { ...candidate, firstRequest, lastRequest, lastResponse }; } - /** - * 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); - } - - 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; - } - 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 @@ -442,8 +390,8 @@ export class ChatSessionRoutingController extends Disposable { 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 + if (target.kind === 'new' && results.length === 0) { + // With no alternatives to show, 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 { @@ -453,9 +401,8 @@ export class ChatSessionRoutingController extends Disposable { /** * 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. + * auto-sends. The user can select another destination, choose several, + * abort, or keep typing to cancel before it fires. */ private _renderCountdownBadge( badge: HTMLElement, @@ -469,17 +416,89 @@ export class ChatSessionRoutingController extends Disposable { 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')); + badge.classList.add('chat-routing-badge-ranked'); + + const labelById = new Map(candidates.map(candidate => [candidate.sessionId, candidate.label])); + const ranked = results + .filter(result => labelById.has(result.sessionId)) + .sort((a, b) => b.confidence - a.confidence) + .slice(0, ROUTE_MAX_CHOICES) + .map(result => ({ + kind: 'session' as const, + sessionId: result.sessionId, + label: labelById.get(result.sessionId) ?? result.sessionId, + confidence: result.confidence, + })); + const options: PendingTarget[] = [ + ...ranked, + { kind: 'new', label: localize('chatSessionRouting.startNewSession', "Start a new session") }, + ]; + const preselected = Math.max(0, options.findIndex(option => + target.kind === 'session' + ? option.kind === 'session' && option.sessionId === target.sessionId + : option.kind === 'new')); + const selection = new Set([preselected]); + + const head = dom.append(badge, dom.$('.chat-routing-badge-head')); + const headLabel = dom.append(head, dom.$('span.chat-routing-badge-title')); + const countdownEl = dom.append(head, dom.$('span.chat-routing-badge-countdown')); + const list = dom.append(badge, dom.$('.chat-routing-badge-list', { role: 'listbox', 'aria-label': localize('chatSessionRouting.sendTo', "Send to") })); + const rows = options.map((option, index) => { + const row = dom.append(list, dom.$('.chat-routing-badge-row', { role: 'option', tabindex: '0' })); + const mark = dom.append(row, dom.$('span.chat-routing-badge-mark')); + mark.appendChild(renderIcon(Codicon.pass)); + const label = dom.append(row, dom.$('span.chat-routing-badge-name')); + label.textContent = option.label; + if (option.kind === 'session') { + const meter = dom.append(row, dom.$('span.chat-routing-badge-meter')); + const fill = dom.append(meter, dom.$('span')); + fill.style.width = `${Math.round(option.confidence * 100)}%`; + } + const score = dom.append(row, dom.$('span.chat-routing-badge-score')); + score.textContent = option.kind === 'session' + ? localize('chatSessionRouting.match', "{0}%", Math.round(option.confidence * 100)) + : ''; + store.add(dom.addDisposableListener(row, dom.EventType.CLICK, event => { + if (event.ctrlKey || event.metaKey) { + if (selection.has(index) && selection.size > 1) { + selection.delete(index); + } else { + selection.add(index); + } + countdownTimer.clear(); + countdownEl.textContent = localize('chatSessionRouting.waiting', "waiting for you"); + renderSelection(); + return; + } + selection.clear(); + selection.add(index); + renderSelection(); + send(); + })); + return row; + }); - 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); + const foot = dom.append(badge, dom.$('.chat-routing-badge-foot')); + const changeHint = dom.append(foot, dom.$('span')); + changeHint.textContent = localize('chatSessionRouting.changeHint', "\u2325 to change \u00B7 \u2318click for several"); + const sendHint = dom.append(foot, dom.$('span.chat-routing-badge-foot-end')); + + const renderSelection = () => { + rows.forEach((row, index) => { + const selected = selection.has(index); + row.classList.toggle('selected', selected); + row.setAttribute('aria-selected', String(selected)); + row.tabIndex = selected ? 0 : -1; + }); + list.classList.toggle('multiple', selection.size > 1); + headLabel.textContent = selection.size > 1 + ? localize('chatSessionRouting.sendToMany', "Send to {0} sessions", selection.size) + : localize('chatSessionRouting.sendTo', "Send to"); + sendHint.textContent = selection.size > 1 + ? localize('chatSessionRouting.sendAllHint', "Enter to send to all") + : localize('chatSessionRouting.sendNowHint', "Enter to send now"); }; - renderLabel(); + renderSelection(); let remainingSeconds = Math.ceil(ROUTE_AUTOSEND_DELAY_MS / 1000); const renderCountdown = () => { @@ -487,22 +506,26 @@ export class ChatSessionRoutingController extends Disposable { }; 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(); - 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); + const sent = [...selection].sort((a, b) => a - b).map(index => options[index]); + if (!sent.length) { + return; + } + const dispatches = sent.map(selected => + this._dispatchTo(selected, submittedInput, utterance, attachedContext, cts.token) + ); + if (sent.length > 1) { + this._showFanoutConfirmation(sent.length); + return; + } + void dispatches[0].then(ok => { + const selected = sent[0]; + if (ok && selected.kind === 'session' && this._submitCts.value === cts) { + this._showSentConfirmation(selected.label, selected.sessionId); } }); }; - // 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); @@ -523,32 +546,26 @@ export class ChatSessionRoutingController extends Disposable { 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; + store.add(dom.addDisposableListener(targetWindow, dom.EventType.KEY_DOWN, event => { + const keyboardEvent = new StandardKeyboardEvent(event); + if (keyboardEvent.equals(KeyCode.Alt)) { + keyboardEvent.preventDefault(); + const from = selection.size === 1 ? [...selection][0] : preselected; + selection.clear(); + selection.add((from + 1) % options.length); + renderSelection(); + countdownTimer.clear(); + countdownEl.textContent = localize('chatSessionRouting.waiting', "waiting for you"); + } else if (keyboardEvent.equals(KeyCode.Enter)) { + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); + send(); + } else if (keyboardEvent.equals(KeyCode.Escape)) { + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); + cancel(); } - 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); + }, true)); // Typing in the input cancels the auto-send so an edit never silently sends. store.add(this.host.widget.inputEditor.onDidChangeModelContent(() => cancel())); @@ -611,6 +628,8 @@ export class ChatSessionRoutingController extends Disposable { } const badge = dom.$('.chat-routing-badge'); + const mark = dom.append(badge, dom.$('span.chat-routing-badge-sent-mark')); + mark.appendChild(renderIcon(Codicon.pass)); const labelEl = dom.append(badge, dom.$('span.chat-routing-badge-label')); labelEl.textContent = localize('chatSessionRouting.sentTo', "Sent to {0}", label); this.host.placeBadge(badge); @@ -634,6 +653,29 @@ export class ChatSessionRoutingController extends Disposable { this._pendingSend.value = store; } + private _showFanoutConfirmation(count: number): void { + const badge = dom.$('.chat-routing-badge'); + const mark = dom.append(badge, dom.$('span.chat-routing-badge-sent-mark')); + mark.appendChild(renderIcon(Codicon.pass)); + const label = dom.append(badge, dom.$('span.chat-routing-badge-label')); + label.textContent = localize('chatSessionRouting.sentToMany', "Sent to {0} sessions", count); + this.host.placeBadge(badge); + if (!badge.parentElement) { + return; + } + + const store = new DisposableStore(); + store.add(toDisposable(() => badge.remove())); + 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' })); diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/media/chatSessionRouting.css b/src/vs/workbench/contrib/chat/browser/sessionRouter/media/chatSessionRouting.css index da0465492d7815..8598f2aec643d5 100644 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/media/chatSessionRouting.css +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/media/chatSessionRouting.css @@ -15,7 +15,7 @@ line-height: 20px; color: var(--vscode-foreground); background-color: var(--vscode-editorWidget-background); - border-bottom: 1px solid var(--vscode-editorWidget-border, transparent); + border-top: 1px solid var(--vscode-editorWidget-border, transparent); } .chat-routing-badge .chat-routing-badge-label { @@ -47,5 +47,156 @@ .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); + border-radius: var(--vscode-cornerRadius-small); +} + +.chat-routing-badge.chat-routing-badge-ranked { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; + padding: 6px 0 4px; + line-height: normal; +} + +.chat-routing-badge-head { + display: flex; + align-items: baseline; + gap: 8px; + padding: 0 var(--omni-rail, 14px) 4px; +} + +.chat-routing-badge-title { + flex: 1 1 auto; + font-size: 11px; + letter-spacing: .06em; + text-transform: uppercase; + opacity: .5; +} + +.chat-routing-badge-list { + display: flex; + flex-direction: column; + padding: 0 calc(var(--omni-rail, 14px) - 8px); +} + +.chat-routing-badge-row { + display: flex; + align-items: center; + gap: var(--omni-row-gap, 8px); + padding: 4px 8px; + border-radius: var(--vscode-cornerRadius-medium); + cursor: pointer; + -webkit-app-region: no-drag; +} + +.chat-routing-badge-row:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.chat-routing-badge-row:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.chat-routing-badge-row.selected { + position: relative; + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.chat-routing-badge-row.selected::before { + content: ''; + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 2px; + border-radius: var(--vscode-cornerRadius-circle); + background-color: var(--vscode-focusBorder); +} + +.chat-routing-badge-row.selected .chat-routing-badge-score { + color: inherit; +} + +.chat-routing-badge-list.multiple .chat-routing-badge-row.selected::before { + display: none; +} + +.chat-routing-badge-mark { + flex: 0 0 auto; + width: var(--omni-icon-column, 16px); + display: flex; + align-items: center; + justify-content: center; + visibility: hidden; +} + +.chat-routing-badge-row.selected .chat-routing-badge-mark { + visibility: visible; +} + +.chat-routing-badge-mark .codicon[class*='codicon-'] { + font-size: var(--vscode-codiconFontSize-compact); +} + +.chat-routing-badge-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-routing-badge-score { + flex: 0 0 auto; + min-width: 4ch; + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--vscode-descriptionForeground); +} + +.chat-routing-badge-meter { + flex: 0 0 auto; + width: 44px; + height: 3px; + border-radius: var(--vscode-cornerRadius-circle); + background-color: color-mix(in srgb, var(--vscode-foreground) 16%, transparent); + overflow: hidden; +} + +.chat-routing-badge-meter > span { + display: block; + height: 100%; + border-radius: inherit; + background-color: color-mix(in srgb, var(--vscode-foreground) 55%, transparent); +} + +.chat-routing-badge-row.selected .chat-routing-badge-meter > span { + background-color: var(--vscode-foreground); +} + +.chat-routing-badge-foot { + display: flex; + align-items: center; + gap: 8px; + padding: 5px var(--omni-rail, 14px) 0; + font-size: 11px; + opacity: .5; +} + +.chat-routing-badge-foot-end { + margin-left: auto; +} + +.chat-routing-badge-sent-mark { + flex: 0 0 auto; + display: flex; + align-items: center; + color: var(--vscode-foreground); +} + +.chat-routing-badge-sent-mark .codicon[class*='codicon-'] { + font-size: var(--vscode-codiconFontSize-compact); } diff --git a/src/vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.ts b/src/vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.ts index 0a893f38ff76a6..35015f8ff6b6b4 100644 --- a/src/vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.ts +++ b/src/vs/workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.ts @@ -89,6 +89,8 @@ export interface IAuxiliaryWindow extends IDisposable { updateOptions(options: { compact: boolean } | undefined): void; + setBounds(bounds: IRectangle): Promise; + layout(): void; createState(): IAuxiliaryWindowOpenOptions; @@ -138,6 +140,11 @@ export class AuxiliaryWindow extends BaseWindow implements IAuxiliaryWindow { this.compact = options.compact; } + async setBounds(bounds: IRectangle): Promise { + this.window.moveTo(bounds.x, bounds.y); + this.window.resizeTo(bounds.width, bounds.height); + } + private registerListeners(): void { this._register(addDisposableListener(this.window, EventType.BEFORE_UNLOAD, (e: BeforeUnloadEvent) => this.handleBeforeUnload(e))); this._register(addDisposableListener(this.window, EventType.UNLOAD, () => this.handleUnload())); diff --git a/src/vs/workbench/services/auxiliaryWindow/electron-browser/auxiliaryWindowService.ts b/src/vs/workbench/services/auxiliaryWindow/electron-browser/auxiliaryWindowService.ts index ae2faa7b571392..1a35eedb2702b7 100644 --- a/src/vs/workbench/services/auxiliaryWindow/electron-browser/auxiliaryWindowService.ts +++ b/src/vs/workbench/services/auxiliaryWindow/electron-browser/auxiliaryWindowService.ts @@ -26,6 +26,7 @@ import { IWorkbenchEnvironmentService } from '../../environment/common/environme import { isMacintosh } from '../../../../base/common/platform.js'; import { assert } from '../../../../base/common/assert.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IRectangle } from '../../../../platform/window/common/window.js'; type NativeCodeWindow = CodeWindow & { readonly vscode: ISandboxGlobals; @@ -100,6 +101,10 @@ export class NativeAuxiliaryWindow extends AuxiliaryWindow { } } + override setBounds(bounds: IRectangle): Promise { + return this.nativeHostService.positionWindow(bounds, { targetWindowId: this.window.vscodeWindowId }); + } + protected override async handleVetoBeforeClose(e: BeforeUnloadEvent, veto: string): Promise { this.preventUnload(e); From 90ff5915d43f0bc4f2f3cad34b6c342458b918e4 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 15:43:38 -0400 Subject: [PATCH 23/37] Fix omni chat layout sizing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11adb7d-72c1-4e64-ba1e-f798dd9bf490 --- .../chatInputWindow/chatInputWindowService.ts | 92 ++++++++++--------- .../chatInputWindow/media/chatInputWindow.css | 6 +- .../chatSessionRoutingController.ts | 2 +- 3 files changed, 51 insertions(+), 49 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index 6900cfa479e25b..a69c98f166418b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -55,6 +55,8 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private _modelRef: IChatModelReference | undefined; /** The single input row; routing results are inserted immediately after it. */ private _row: HTMLElement | undefined; + private _lead: HTMLElement | undefined; + private _trail: HTMLElement | undefined; /** 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. */ @@ -167,22 +169,13 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind 'aria-hidden': 'true', title: localize('chatInputWindow.drag', "Drag to move"), })); + this._lead = lead; lead.style.setProperty('-webkit-app-region', 'drag'); const dragGlyph = dom.append(lead, dom.$('span.chat-input-window-drag-glyph')); dom.append(dragGlyph, dom.$('span')); dom.append(dragGlyph, dom.$('span')); dom.append(dragGlyph, dom.$('span')); - const contentSurface = dom.append(row, dom.$('.chat-input-window-content')); - contentSurface.style.boxSizing = 'border-box'; - contentSurface.style.flex = '1 1 auto'; - contentSurface.style.minWidth = '0'; - contentSurface.style.height = `${CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT}px`; - contentSurface.style.overflow = 'visible'; - contentSurface.style.backgroundColor = 'transparent'; - contentSurface.style.border = 'none'; - contentSurface.style.display = 'flex'; - contentSurface.style.flexDirection = 'column'; applyThemeColors(); this._windowDisposables.add(this.themeService.onDidColorThemeChange(() => applyThemeColors())); @@ -190,9 +183,10 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind // compact ChatWidget. The response list is filtered out so only the input // box shows. Submission is intercepted via submitHandler (the routing // seam) and routed to the best-matching existing session. - this._renderChatWidget(auxiliaryWindow, contentSurface); + this._renderChatWidget(auxiliaryWindow, row); const trail = dom.append(row, dom.$('.chat-input-window-trail')); + this._trail = trail; const close = dom.append(trail, dom.$('a.chat-input-window-close', { role: 'button', tabindex: '0', @@ -249,17 +243,14 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } } - private _renderChatWidget(auxiliaryWindow: IAuxiliaryWindow, contentSurface: HTMLElement): void { + private _renderChatWidget(auxiliaryWindow: IAuxiliaryWindow, row: HTMLElement): 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. It renders above the drag layer (see chatInputWindow.css) - // so its controls stay interactive; the input-box chrome around them - // falls through to the drag layer and moves the window. - const parent = dom.append(contentSurface, dom.$('.interactive-session')); + // wrapper here. + const parent = dom.append(row, dom.$('.interactive-session')); parent.style.flex = '1 1 auto'; - parent.style.minHeight = '0'; - parent.style.width = '100%'; + parent.style.minWidth = '0'; const scopedContextKeyService = this._windowDisposables.add(this.contextKeyService.createScoped(parent)); // Mark this surface so its dedicated accessibility help (routing + how to @@ -341,13 +332,9 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind }; this._routingController = this._windowDisposables.add(this.instantiationService.createInstance(ChatSessionRoutingController, host, 'chatInputWindow')); - const layout = () => widget.layout(parent.offsetHeight, parent.offsetWidth); - layout(); - - // Fit the frameless window to the input row and any routing panel below - // it. Skip while the model picker has grown the window so we do not fight - // its manual resize. Only touch the OS - // window when the size actually changed. + // Fit the frameless window to the widget's own content and any routing + // panel below it. Measuring the input container itself includes the + // height the host assigned and creates a feedback loop with empty space. let lastContentHeight: number | undefined; let didInitialPosition = false; fitWindowToInput = () => { @@ -358,11 +345,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind if (!win || win !== auxiliaryWindow.window) { return; } - const inputHeight = widget.input.inputContainerElement?.getBoundingClientRect().height; - const rowHeight = inputHeight === undefined - ? CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT - : Math.max(CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT, Math.ceil(inputHeight)); - contentSurface.style.height = `${rowHeight}px`; + const rowHeight = Math.max(CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT, Math.ceil(widget.contentHeight)); const extraHeight = Array.from(auxiliaryWindow.container.children) .filter(child => child !== this._row) .reduce((height, child) => height + (child as HTMLElement).offsetHeight, 0); @@ -382,23 +365,40 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind void auxiliaryWindow.setBounds({ x, y, width: win.outerWidth, height: contentHeight }); }; - // Keep the window fitted as the input grows/shrinks (e.g. multi-line - // text), instead of relying on a single racy measurement that can catch - // the input before it collapses to its compact single-line height. Defer - // the fit out of the observer callback so the `resizeTo`/`moveTo` don't - // reenter the layout pass that triggered the notification. - const inputContainer = widget.input.inputContainerElement; - if (inputContainer && typeof auxiliaryWindow.window.ResizeObserver === 'function') { - const scheduledFit = this._windowDisposables.add(new MutableDisposable()); - const resizeObserver = new auxiliaryWindow.window.ResizeObserver(() => { - scheduledFit.value = dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => fitWindowToInput()); - }); - resizeObserver.observe(inputContainer); - this._windowDisposables.add(toDisposable(() => resizeObserver.disconnect())); - } + let layingOut = false; + const layout = () => { + if (layingOut) { + return; + } + layingOut = true; + try { + const chrome = (this._lead?.offsetWidth ?? 0) + (this._trail?.offsetWidth ?? 0); + const rowStyle = auxiliaryWindow.window.getComputedStyle(row); + const horizontalPadding = Number.parseFloat(rowStyle.paddingLeft) + Number.parseFloat(rowStyle.paddingRight); + const available = Math.max(0, row.clientWidth - chrome - horizontalPadding); + parent.style.width = `${available}px`; + widget.layout(widget.contentHeight || row.offsetHeight, available); + + const spill = parent.scrollWidth - parent.clientWidth; + if (spill > 0) { + widget.layout(widget.contentHeight || row.offsetHeight, Math.max(0, available - spill)); + } + fitWindowToInput(); + } finally { + layingOut = false; + } + }; + layout(); + this._windowDisposables.add(widget.onDidChangeHeight(() => layout())); + const scheduledInputLayout = this._windowDisposables.add(new MutableDisposable()); + this._windowDisposables.add(widget.inputEditor.onDidChangeModelContent(() => { + // Submit controls change after the editor event; measure them in the + // next frame so the editor yields space before they can cover close. + scheduledInputLayout.value = dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => layout()); + })); this._windowDisposables.add(dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => { - fitWindowToInput(); + layout(); // Focus the input only after the window has been positioned: the // `moveTo`/`resizeTo` above blur the editor, so focusing in a // follow-up frame (after the OS window is settled and keyed) is what @@ -445,6 +445,8 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private _disposeWidget(): void { this._routingController = undefined; this._row = undefined; + this._lead = undefined; + this._trail = undefined; this._actionWidgetRestoreHeight = undefined; this._modelRef?.dispose(); this._modelRef = undefined; diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css b/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css index 87ffd9f8d15848..02321b75fdb93d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css @@ -20,7 +20,7 @@ .chat-input-window .chat-input-window-row { display: flex; align-items: center; - flex: 0 0 auto; + flex: 1 1 auto; min-height: 44px; padding: 0 8px; } @@ -103,8 +103,8 @@ background: transparent !important; } -.chat-input-window .chat-input-window-content, -.chat-input-window .chat-input-window-content > .interactive-session, +.chat-input-window .chat-input-window-row > .interactive-session, +.chat-input-window .interactive-session, .chat-input-window .interactive-input-part, .chat-input-window .interactive-input-and-edit-session, .chat-input-window .interactive-input-and-side-toolbar, diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts index 58d889490ab8de..cea72f56cc6df9 100644 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts @@ -480,7 +480,7 @@ export class ChatSessionRoutingController extends Disposable { const foot = dom.append(badge, dom.$('.chat-routing-badge-foot')); const changeHint = dom.append(foot, dom.$('span')); - changeHint.textContent = localize('chatSessionRouting.changeHint', "\u2325 to change \u00B7 \u2318click for several"); + changeHint.textContent = localize('chatSessionRouting.changeHint', "\u2325 to change \u00B7 \u2318click for several \u00B7 Escape to cancel"); const sendHint = dom.append(foot, dom.$('span.chat-routing-badge-foot-end')); const renderSelection = () => { From cd96f1026a033f6129e319551aab4020aabbc970 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 15:52:41 -0400 Subject: [PATCH 24/37] Grow omni chat input with content Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11adb7d-72c1-4e64-ba1e-f798dd9bf490 --- .../browser/chatInputWindow/chatInputWindowService.ts | 6 +++++- .../browser/chatInputWindow/media/chatInputWindow.css | 11 ++++++++++- 2 files changed, 15 insertions(+), 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 a69c98f166418b..fa37601ea105f0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -34,6 +34,9 @@ import { IChatInputWindowService, ChatInputWindowStorageKeys, CHAT_INPUT_WINDOW_ const CHAT_INPUT_WINDOW_MODEL_PICKER_HEIGHT = 420; const CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT = 44; +// ChatWidget reserves 50px for its response list. This budget leaves the +// compact editor its full 83px intrinsic maximum while the list stays hidden. +const CHAT_INPUT_WINDOW_WIDGET_HEIGHT_BUDGET = 133; /** * Hosts a frameless, always-on-top auxiliary window containing the full chat @@ -293,6 +296,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind )); widget.render(parent); widget.setVisible(true); + widget.setInputPartMaxHeightOverride(CHAT_INPUT_WINDOW_WIDGET_HEIGHT_BUDGET); const modelRef = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { disableBackgroundKeepAlive: true, debugOwner: 'ChatInputWindow' }); this._modelRef = modelRef; @@ -389,7 +393,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } }; layout(); - this._windowDisposables.add(widget.onDidChangeHeight(() => layout())); + this._windowDisposables.add(widget.onDidChangeContentHeight(() => fitWindowToInput())); const scheduledInputLayout = this._windowDisposables.add(new MutableDisposable()); this._windowDisposables.add(widget.inputEditor.onDidChangeModelContent(() => { // Submit controls change after the editor event; measure them in the diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css b/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css index 02321b75fdb93d..b286d3bb81b73f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css @@ -20,7 +20,7 @@ .chat-input-window .chat-input-window-row { display: flex; align-items: center; - flex: 1 1 auto; + flex: 0 0 auto; min-height: 44px; padding: 0 8px; } @@ -93,6 +93,15 @@ display: none; } +.chat-input-window .interactive-session { + height: auto; + justify-content: center; +} + +.chat-input-window .interactive-input-part.compact { + padding-top: 0; +} + .chat-input-window .chat-input-toolbars .chat-input-picker-item, .chat-input-window .chat-input-toolbars .action-item > .codicon-add { display: none; From 1431c53a616c4b9b10344c91ee2d3b08eef0758f Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 16:02:51 -0400 Subject: [PATCH 25/37] Fix opening the omni chat window Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11adb7d-72c1-4e64-ba1e-f798dd9bf490 --- .../browser/chatInputWindow/chatInputWindowService.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index fa37601ea105f0..68ea500206fc21 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -34,9 +34,6 @@ import { IChatInputWindowService, ChatInputWindowStorageKeys, CHAT_INPUT_WINDOW_ const CHAT_INPUT_WINDOW_MODEL_PICKER_HEIGHT = 420; const CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT = 44; -// ChatWidget reserves 50px for its response list. This budget leaves the -// compact editor its full 83px intrinsic maximum while the list stays hidden. -const CHAT_INPUT_WINDOW_WIDGET_HEIGHT_BUDGET = 133; /** * Hosts a frameless, always-on-top auxiliary window containing the full chat @@ -296,7 +293,6 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind )); widget.render(parent); widget.setVisible(true); - widget.setInputPartMaxHeightOverride(CHAT_INPUT_WINDOW_WIDGET_HEIGHT_BUDGET); const modelRef = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { disableBackgroundKeepAlive: true, debugOwner: 'ChatInputWindow' }); this._modelRef = modelRef; @@ -381,11 +377,14 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind const horizontalPadding = Number.parseFloat(rowStyle.paddingLeft) + Number.parseFloat(rowStyle.paddingRight); const available = Math.max(0, row.clientWidth - chrome - horizontalPadding); parent.style.width = `${available}px`; - widget.layout(widget.contentHeight || row.offsetHeight, available); + widget.input.layout(available); + widget.layoutForInputHeight(Math.max(CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT, widget.contentHeight), available); const spill = parent.scrollWidth - parent.clientWidth; if (spill > 0) { - widget.layout(widget.contentHeight || row.offsetHeight, Math.max(0, available - spill)); + const compensatedWidth = Math.max(0, available - spill); + widget.input.layout(compensatedWidth); + widget.layoutForInputHeight(Math.max(CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT, widget.contentHeight), compensatedWidth); } fitWindowToInput(); } finally { From fb05a1cda2247d23ec4813783d94bd0906dd7546 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 16:13:05 -0400 Subject: [PATCH 26/37] Route omni voice to selected sessions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11adb7d-72c1-4e64-ba1e-f798dd9bf490 --- .../lib/stylelint/vscode-known-variables.json | 2 +- .../chatInputWindow/chatInputWindowService.ts | 9 +++++- .../chatInputWindow/media/chatInputWindow.css | 9 ++++++ .../chatSessionRoutingController.ts | 32 +++++++++++++------ .../voiceClient/voiceSessionController.ts | 11 +++++-- .../common/voiceClient/voiceClientService.ts | 2 ++ .../voiceSessionController.test.ts | 17 ++++++++++ 7 files changed, 68 insertions(+), 14 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 628fa4b6e6f236..b13dbf52a6f08b 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1117,7 +1117,7 @@ "--slide-from-y", "--omni-icon-column", "--omni-rail", - "--omni-row-gap" + "--omni-row-gap", "--vg-w1", "--vg-h1", "--vg-w2", diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index 68ea500206fc21..a341b5724cca00 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -30,6 +30,7 @@ import { IChatModelReference, IChatService } from '../../common/chatService/chat import { ChatWidget } from '../widget/chatWidget.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatSessionRoutingController, IChatSessionRoutingHost } from '../sessionRouter/chatSessionRoutingController.js'; +import { IVoiceSessionController } from '../voiceClient/voiceSessionController.js'; import { IChatInputWindowService, ChatInputWindowStorageKeys, CHAT_INPUT_WINDOW_DEFAULT_HEIGHT } from '../../common/chatInputWindow.js'; const CHAT_INPUT_WINDOW_MODEL_PICKER_HEIGHT = 420; @@ -77,6 +78,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IChatService private readonly chatService: IChatService, + @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, ) { super(); @@ -306,6 +308,11 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind const host: IChatSessionRoutingHost = { widget, getOwnSessionResource: () => this._modelRef?.object.sessionResource, + onDidResolveRoute: (resource, kind) => { + if (this.voiceSessionController.isConnected.get() || this.voiceSessionController.isConnecting.get()) { + this.voiceSessionController.setTargetSession(resource, kind); + } + }, placeBadge: (badge) => { const container = this._window?.container; const row = this._row; @@ -345,7 +352,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind if (!win || win !== auxiliaryWindow.window) { return; } - const rowHeight = Math.max(CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT, Math.ceil(widget.contentHeight)); + const rowHeight = Math.max(CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT, Math.ceil(row.scrollHeight)); const extraHeight = Array.from(auxiliaryWindow.container.children) .filter(child => child !== this._row) .reduce((height, child) => height + (child as HTMLElement).offsetHeight, 0); diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css b/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css index b286d3bb81b73f..f68bd42d4217ad 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css @@ -12,6 +12,11 @@ overflow: hidden; } +.chat-input-window:focus-within { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + .chat-input-window-body { background: transparent !important; overflow: hidden; @@ -98,6 +103,10 @@ justify-content: center; } +.chat-input-window .interactive-list { + display: none; +} + .chat-input-window .interactive-input-part.compact { padding-top: 0; } diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts index cea72f56cc6df9..18b3c786c145e6 100644 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts @@ -128,6 +128,8 @@ export interface IChatSessionRoutingHost { * the controller will fall back to an immediate dispatch. */ placeBadge(badge: HTMLElement): void; + /** Notify the host when a single-target route resolves, or clear it for fan-out. */ + onDidResolveRoute?(resource: URI | undefined, kind?: 'existing_session' | 'new_session'): void; } /** @@ -511,8 +513,11 @@ export class ChatSessionRoutingController extends Disposable { if (!sent.length) { return; } + if (sent.length > 1) { + this.host.onDidResolveRoute?.(undefined); + } const dispatches = sent.map(selected => - this._dispatchTo(selected, submittedInput, utterance, attachedContext, cts.token) + this._dispatchTo(selected, submittedInput, utterance, attachedContext, cts.token, sent.length === 1) ); if (sent.length > 1) { this._showFanoutConfirmation(sent.length); @@ -603,6 +608,7 @@ export class ChatSessionRoutingController extends Disposable { return; } const sessionResource = resource; + this.host.onDidResolveRoute?.(sessionResource, 'new_session'); label.textContent = localize('chatSessionRouting.noMatch', "No matching chat found — sent to a new chat"); this._addActionLink(store, badge, localize('chatSessionRouting.openNewChat', "Open new chat"), () => { @@ -690,11 +696,11 @@ export class ChatSessionRoutingController extends Disposable { } /** 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 { + private async _dispatchTo(target: PendingTarget, submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken, notifyRoute = true): Promise { if (target.kind === 'new') { - return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token, notifyRoute); } - return this._dispatchToSession(target.sessionId, submittedInput, utterance, attachedContext, token); + return this._dispatchToSession(target.sessionId, submittedInput, utterance, attachedContext, token, notifyRoute); } /** Send to an already-created new session (used by the no-delay no-match flow). */ @@ -716,13 +722,13 @@ export class ChatSessionRoutingController extends Disposable { } } - private async _dispatchToSession(sessionId: string, submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken): Promise { + private async _dispatchToSession(sessionId: string, submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken, notifyRoute: boolean): 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); + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token, notifyRoute); } try { @@ -733,16 +739,19 @@ export class ChatSessionRoutingController extends Disposable { } if (!ref) { this.logService.warn('[chatSessionRouting] could not load routed session, starting a new one:', sessionId); - return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token, notifyRoute); } this._retainSessionRef(target, ref); + if (notifyRoute) { + this.host.onDidResolveRoute?.(target, 'existing_session'); + } 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); + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token, notifyRoute); } // Remember this session so the next request biases toward it. this.storageService.store(LAST_TARGET_STORAGE_KEY, sessionId, StorageScope.WORKSPACE, StorageTarget.MACHINE); @@ -753,11 +762,11 @@ export class ChatSessionRoutingController extends Disposable { return true; } this.logService.warn('[chatSessionRouting] error dispatching to routed session, starting a new one:', err); - return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token); + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token, notifyRoute); } } - private async _dispatchToNewSession(submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken): Promise { + private async _dispatchToNewSession(submittedInput: string, utterance: string, attachedContext: IChatRequestVariableEntry[] | undefined, token: CancellationToken, notifyRoute: boolean): Promise { try { const ref = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { debugOwner: `${this.debugOwner}-new` }); if (token.isCancellationRequested) { @@ -765,6 +774,9 @@ export class ChatSessionRoutingController extends Disposable { return true; } this._retainSessionRef(ref.object.sessionResource, ref); + if (notifyRoute) { + this.host.onDidResolveRoute?.(ref.object.sessionResource, 'new_session'); + } const result = await this.chatService.sendRequest(ref.object.sessionResource, utterance, attachedContext?.length ? { attachedContext } : undefined); if (token.isCancellationRequested) { return true; diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index daca646139b140..17d3d376ed6903 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -251,7 +251,7 @@ export interface IVoiceSessionController { * Set the target session for transcription. When set, transcriptions are * sent to this session instead of the currently active one. */ - setTargetSession(resource: URI | undefined): void; + setTargetSession(resource: URI | undefined, omniRoute?: 'existing_session' | 'new_session'): void; /** * Create a new chat session and set it as the target for transcription. @@ -332,6 +332,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private readonly _targetSession = observableValue(this, undefined); readonly targetSession: IObservable = this._targetSession; + private _targetOmniRoute: 'existing_session' | 'new_session' | undefined; // --- Internal state --- private _pttHeld = false; @@ -2078,6 +2079,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // Terminal disconnect: drop the routing target and pending-confirmation // snapshot (and suppress the tracker) so a later reconnect can't re-pin // voice to the old session or repopulate its stale confirmation. + this._targetOmniRoute = undefined; this._targetSession.set(undefined, undefined); this._suppressPendingConfirmationsUntilConnect(); this._pendingToolConfirmations.set([], undefined); @@ -2217,6 +2219,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // Terminal disconnect (no reconnect): drop the routing target and // pending-confirmation snapshot, and suppress the tracker so connect() // isn't re-pinned to this evicted session (see disconnect()). + this._targetOmniRoute = undefined; this._targetSession.set(undefined, undefined); this._suppressPendingConfirmationsUntilConnect(); this._pendingToolConfirmations.set([], undefined); @@ -2739,7 +2742,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._userCancelledSessions.set(sessionId, expiry); } - setTargetSession(resource: URI | undefined): void { + setTargetSession(resource: URI | undefined, omniRoute?: 'existing_session' | 'new_session'): void { + this._targetOmniRoute = resource ? omniRoute : undefined; this._targetSession.set(resource, undefined); } @@ -2747,6 +2751,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const ref = this.chatService.startNewLocalSession(ChatAgentLocation.Chat); const resource = ref.object.sessionResource; ref.dispose(); + this._targetOmniRoute = undefined; this._targetSession.set(resource, undefined); // Try to switch the view to the new session (works if chat pane is open) this.commandService.executeCommand('_chat.voice.switchToSession', resource.toString()).catch(() => { /* pane may not exist */ }); @@ -5798,6 +5803,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC id: s.resource.toString(), ...(s.label ? { label: s.label } : {}), is_active: isActive, + ...(isActive && s.resource.toString() === this._targetSession.get()?.toString() && this._targetOmniRoute ? { omni_route: this._targetOmniRoute } : {}), agent_state: scoped.state, ...(!scoped.hideConfirmationDetail && stateInfo.detail ? { agent_state_detail: stateInfo.detail } : {}), ...(!scoped.hideConfirmationDetail && stateInfo.confirmation_type ? { confirmation_type: stateInfo.confirmation_type } : {}), @@ -5826,6 +5832,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC id: key, ...(chatModel.title ? { label: chatModel.title } : {}), is_active: isActive, + ...(isActive && key === this._targetSession.get()?.toString() && this._targetOmniRoute ? { omni_route: this._targetOmniRoute } : {}), agent_state: scoped.state, ...(!scoped.hideConfirmationDetail && stateInfo.detail ? { agent_state_detail: stateInfo.detail } : {}), ...(!scoped.hideConfirmationDetail && stateInfo.confirmation_type ? { confirmation_type: stateInfo.confirmation_type } : {}), diff --git a/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts b/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts index 1a83c62aed20d6..4964b17b0460a7 100644 --- a/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts +++ b/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts @@ -101,6 +101,8 @@ export interface IVoiceSessionContext { /** Human-readable name, so the backend can tell two sessions apart. */ label?: string; is_active: boolean; + /** Omni routing decision for backend narration of the selected target. */ + omni_route?: 'existing_session' | 'new_session'; agent_state: string; agent_state_detail?: string; confirmation_type?: VoiceConfirmationType; diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts index 48345dd5c6545b..d640de822bdd6e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts @@ -3581,6 +3581,23 @@ suite('VoiceSessionController', () => { assert.strictEqual(session.label, 'Auth fix'); }); + test('marks an omni-routed target for backend narration', () => { + const resource = URI.parse('vscode-chat://a'); + const controller = createController( + new TestVoiceClientService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, + new TestAgentSessionsService([agentSessionEntry(resource.toString(), 'Auth fix', AgentSessionStatus.InProgress)]), + ); + const buildSessionContext = Reflect.get(controller, '_buildSessionContext') as () => { + sessions: { id: string; is_active: boolean; omni_route?: string }[]; + }; + + controller.setTargetSession(resource, 'new_session'); + const [session] = buildSessionContext.call(controller).sessions; + + assert.strictEqual(session.is_active, true); + assert.strictEqual(session.omni_route, 'new_session'); + }); + test('an older tool confirmation holds the turn ahead of a newer form', () => { // Queue semantics applied uniformly: approve the command you were asked // about, then answer the questions. From 427cba4fdfff27223e7279da89e2ba1402e1256e Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 16:20:25 -0400 Subject: [PATCH 27/37] Fix opening the omni chat window Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11adb7d-72c1-4e64-ba1e-f798dd9bf490 --- .../browser/chatInputWindow/chatInputWindowService.ts | 8 +++----- .../chat/browser/voiceClient/voiceSessionController.ts | 8 ++++++++ src/vs/workbench/contrib/chat/common/chatInputWindow.ts | 9 ++++++++- .../browser/voiceClient/voiceSessionController.test.ts | 7 +++++++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index a341b5724cca00..7e18527119b777 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -30,7 +30,6 @@ import { IChatModelReference, IChatService } from '../../common/chatService/chat import { ChatWidget } from '../widget/chatWidget.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatSessionRoutingController, IChatSessionRoutingHost } from '../sessionRouter/chatSessionRoutingController.js'; -import { IVoiceSessionController } from '../voiceClient/voiceSessionController.js'; import { IChatInputWindowService, ChatInputWindowStorageKeys, CHAT_INPUT_WINDOW_DEFAULT_HEIGHT } from '../../common/chatInputWindow.js'; const CHAT_INPUT_WINDOW_MODEL_PICKER_HEIGHT = 420; @@ -48,6 +47,8 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private readonly _onDidChangeOpen = this._register(new Emitter()); readonly onDidChangeOpen: Event = this._onDidChangeOpen.event; + private readonly _onDidResolveRoute = this._register(new Emitter<{ resource: URI | undefined; kind?: 'existing_session' | 'new_session' }>()); + readonly onDidResolveRoute = this._onDidResolveRoute.event; private readonly _auxiliaryWindowRef = this._register(new MutableDisposable()); private _window: IAuxiliaryWindow | undefined; @@ -78,7 +79,6 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IChatService private readonly chatService: IChatService, - @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, ) { super(); @@ -309,9 +309,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind widget, getOwnSessionResource: () => this._modelRef?.object.sessionResource, onDidResolveRoute: (resource, kind) => { - if (this.voiceSessionController.isConnected.get() || this.voiceSessionController.isConnecting.get()) { - this.voiceSessionController.setTargetSession(resource, kind); - } + this._onDidResolveRoute.fire({ resource, kind }); }, placeBadge: (badge) => { const container = this._window?.container; diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index 17d3d376ed6903..021f6ebdb46461 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -43,6 +43,7 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; +import { IChatInputWindowService } from '../../common/chatInputWindow.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { VoiceFirstConnectClassification, VoiceFirstConnectEvent, @@ -770,9 +771,16 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @INotificationService private readonly notificationService: INotificationService, @IPromptsService private readonly promptsService: IPromptsService, + @IChatInputWindowService chatInputWindowService: IChatInputWindowService, ) { super(); + this._register(chatInputWindowService.onDidResolveRoute(({ resource, kind }) => { + if (this._isConnected.get() || this._isConnecting.get()) { + this.setTargetSession(resource, kind); + } + })); + // Track the focused chat session so we can defer voice responses that // arrive for a session the user isn't currently looking at, and flush // them once that session becomes focused. diff --git a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts index a097a3bd6004e6..d7bec342808061 100644 --- a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts +++ b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts @@ -3,8 +3,9 @@ * 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'; +import { URI } from '../../../../base/common/uri.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; export const CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID = 'workbench.action.chat.toggleInputWindow'; @@ -35,6 +36,12 @@ export interface IChatInputWindowService { */ readonly onDidChangeOpen: Event; + /** + * Fires when omni routing resolves to one session. Voice Mode uses this to + * follow the selected session without being constructed just to open omni. + */ + readonly onDidResolveRoute: Event<{ resource: URI | undefined; kind?: 'existing_session' | 'new_session' }>; + /** * Opens the floating chat input window. No-op if already open. */ diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts index d640de822bdd6e..548a528ae85898 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts @@ -34,6 +34,7 @@ import { IMicCaptureService } from '../../../browser/voiceClient/micCaptureServi import { ITtsPlaybackService } from '../../../browser/voiceClient/ttsPlaybackService.js'; import { IVoiceSessionController, VoiceSessionController } from '../../../browser/voiceClient/voiceSessionController.js'; import { IVoiceToolDispatchService } from '../../../browser/voiceClient/voiceToolDispatchService.js'; +import { IChatInputWindowService } from '../../../common/chatInputWindow.js'; import { ChatSendResult, ElicitationState, IChatConfirmation, IChatSendRequestOptions, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { derivePendingId, IVoiceAudioResponse, IVoiceBargeIn, IVoiceCheckpointNarrationMetadata, IVoiceClientService, IVoiceDispatchResult, IVoiceNarrationAck, IVoiceNarrationSignal, IVoiceSessionContext, IVoiceSpeechStarted, IVoiceToolCall, IVoiceTranscription, peekPendingId, VoiceConfirmationType, VoiceNarrationKind, VOICE_AGENT_PROGRESS_SETTING } from '../../../common/voiceClient/voiceClientService.js'; @@ -511,6 +512,9 @@ suite('VoiceSessionController', () => { new TestChatWidgetService(), notificationService, promptsService, + new class extends mock() { + override readonly onDidResolveRoute = Event.None; + }(), )); } @@ -4530,6 +4534,9 @@ suite('VoiceSessionController live transcription', () => { onDidChangeFocusedSession: Event.None, getAllWidgets: () => [], }); + instantiationService.stub(IChatInputWindowService, { + onDidResolveRoute: Event.None, + }); const controller = store.add(instantiationService.createInstance(VoiceSessionController)); controller['_isConnected'].set(true, undefined); From 4deb0333d7d1c7b8517b517524c47d9dc6991b2c Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 16:22:32 -0400 Subject: [PATCH 28/37] Route focused omni voice without service cycles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11adb7d-72c1-4e64-ba1e-f798dd9bf490 --- .../chatInputWindow/chatInputWindowService.ts | 28 +++++++++-- .../voiceClient/voiceSessionController.ts | 18 ++++--- .../contrib/chat/common/chatInputWindow.ts | 7 --- .../voiceSessionController.test.ts | 48 +++++++++++++++---- 4 files changed, 74 insertions(+), 27 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index 7e18527119b777..4dd95709be5baf 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -16,6 +16,7 @@ import { InstantiationType, registerSingleton } from '../../../../../platform/in 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 { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.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'; @@ -30,6 +31,7 @@ import { IChatModelReference, IChatService } from '../../common/chatService/chat import { ChatWidget } from '../widget/chatWidget.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatSessionRoutingController, IChatSessionRoutingHost } from '../sessionRouter/chatSessionRoutingController.js'; +import { combineVoiceInput } from '../voiceClient/voiceInputUtils.js'; import { IChatInputWindowService, ChatInputWindowStorageKeys, CHAT_INPUT_WINDOW_DEFAULT_HEIGHT } from '../../common/chatInputWindow.js'; const CHAT_INPUT_WINDOW_MODEL_PICKER_HEIGHT = 420; @@ -47,14 +49,13 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private readonly _onDidChangeOpen = this._register(new Emitter()); readonly onDidChangeOpen: Event = this._onDidChangeOpen.event; - private readonly _onDidResolveRoute = this._register(new Emitter<{ resource: URI | undefined; kind?: 'existing_session' | 'new_session' }>()); - readonly onDidResolveRoute = this._onDidResolveRoute.event; private readonly _auxiliaryWindowRef = this._register(new MutableDisposable()); private _window: IAuxiliaryWindow | undefined; private readonly _windowDisposables = this._register(new DisposableStore()); private readonly _ownershipChannel: BroadcastChannel; private _modelRef: IChatModelReference | undefined; + private _widget: ChatWidget | undefined; /** The single input row; routing results are inserted immediately after it. */ private _row: HTMLElement | undefined; private _lead: HTMLElement | undefined; @@ -79,9 +80,12 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IChatService private readonly chatService: IChatService, + @ICommandService private readonly commandService: ICommandService, ) { super(); + this._register(CommandsRegistry.registerCommand('_chat.omni.acceptVoiceInput', (_accessor, text: string) => this.acceptVoiceInput(text))); + const ownershipChannel = new BroadcastChannel('chat-input-window-ownership'); ownershipChannel.onmessage = (e) => { if (e.data?.type === 'claim' && this._window) { @@ -245,6 +249,20 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } } + async acceptVoiceInput(text: string): Promise { + const window = this._window?.window; + const widget = this._widget; + if (!window?.document.hasFocus() || !widget || !this._routingController) { + return false; + } + + await widget.acceptInput(combineVoiceInput(widget.getInput(), text), { + preserveFocus: true, + isVoiceModeInput: true, + }); + return true; + } + private _renderChatWidget(auxiliaryWindow: IAuxiliaryWindow, row: HTMLElement): void { // The glow CSS keys off `.monaco-workbench .interactive-session // .chat-input-container` - the aux container already tracks the @@ -293,6 +311,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind overlayBackground: editorBackground, } )); + this._widget = widget; widget.render(parent); widget.setVisible(true); @@ -309,7 +328,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind widget, getOwnSessionResource: () => this._modelRef?.object.sessionResource, onDidResolveRoute: (resource, kind) => { - this._onDidResolveRoute.fire({ resource, kind }); + this.commandService.executeCommand('_chat.voice.setOmniTarget', resource?.toString(), kind).catch(() => { }); }, placeBadge: (badge) => { const container = this._window?.container; @@ -350,7 +369,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind if (!win || win !== auxiliaryWindow.window) { return; } - const rowHeight = Math.max(CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT, Math.ceil(row.scrollHeight)); + const rowHeight = Math.max(CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT, Math.ceil(widget.contentHeight)); const extraHeight = Array.from(auxiliaryWindow.container.children) .filter(child => child !== this._row) .reduce((height, child) => height + (child as HTMLElement).offsetHeight, 0); @@ -452,6 +471,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private _disposeWidget(): void { this._routingController = undefined; + this._widget = undefined; this._row = undefined; this._lead = undefined; this._trail = undefined; diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index 021f6ebdb46461..e119da33472559 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -43,7 +43,6 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; -import { IChatInputWindowService } from '../../common/chatInputWindow.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { VoiceFirstConnectClassification, VoiceFirstConnectEvent, @@ -771,13 +770,12 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @INotificationService private readonly notificationService: INotificationService, @IPromptsService private readonly promptsService: IPromptsService, - @IChatInputWindowService chatInputWindowService: IChatInputWindowService, ) { super(); - this._register(chatInputWindowService.onDidResolveRoute(({ resource, kind }) => { + this._register(CommandsRegistry.registerCommand('_chat.voice.setOmniTarget', (_accessor, resource: string | undefined, kind?: 'existing_session' | 'new_session') => { if (this._isConnected.get() || this._isConnecting.get()) { - this.setTargetSession(resource, kind); + this.setTargetSession(resource ? URI.parse(resource) : undefined, kind); } })); @@ -3164,9 +3162,15 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC */ private async _sendTranscriptionToChat(text: string): Promise { // A focus-change submit pins routing to the session the user was - // dictating into; it takes priority over the user-picked target and the - // currently focused session so their words land where they were aimed. - const target = this._consumePinnedSubmitSession() ?? this._targetSession.get(); + // dictating into, so it takes priority over whichever surface has focus + // by the time the backend finalizes the turn. + const pinnedTarget = this._consumePinnedSubmitSession(); + const acceptedByOmni = !pinnedTarget && await this.commandService.executeCommand('_chat.omni.acceptVoiceInput', text).catch(() => false); + if (acceptedByOmni) { + return; + } + + const target = pinnedTarget ?? this._targetSession.get(); if (target) { // Check if target is the currently visible session const currentSession = await this.commandService.executeCommand('_chat.voice.getCurrentSession').catch(() => undefined); diff --git a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts index d7bec342808061..7a5a2c11d2a581 100644 --- a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts +++ b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../base/common/event.js'; -import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; export const CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID = 'workbench.action.chat.toggleInputWindow'; @@ -36,12 +35,6 @@ export interface IChatInputWindowService { */ readonly onDidChangeOpen: Event; - /** - * Fires when omni routing resolves to one session. Voice Mode uses this to - * follow the selected session without being constructed just to open omni. - */ - readonly onDidResolveRoute: Event<{ resource: URI | undefined; kind?: 'existing_session' | 'new_session' }>; - /** * Opens the floating chat input window. No-op if already open. */ diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts index 548a528ae85898..b98d28ce2c6d4c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts @@ -34,7 +34,6 @@ import { IMicCaptureService } from '../../../browser/voiceClient/micCaptureServi import { ITtsPlaybackService } from '../../../browser/voiceClient/ttsPlaybackService.js'; import { IVoiceSessionController, VoiceSessionController } from '../../../browser/voiceClient/voiceSessionController.js'; import { IVoiceToolDispatchService } from '../../../browser/voiceClient/voiceToolDispatchService.js'; -import { IChatInputWindowService } from '../../../common/chatInputWindow.js'; import { ChatSendResult, ElicitationState, IChatConfirmation, IChatSendRequestOptions, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { derivePendingId, IVoiceAudioResponse, IVoiceBargeIn, IVoiceCheckpointNarrationMetadata, IVoiceClientService, IVoiceDispatchResult, IVoiceNarrationAck, IVoiceNarrationSignal, IVoiceSessionContext, IVoiceSpeechStarted, IVoiceToolCall, IVoiceTranscription, peekPendingId, VoiceConfirmationType, VoiceNarrationKind, VOICE_AGENT_PROGRESS_SETTING } from '../../../common/voiceClient/voiceClientService.js'; @@ -429,13 +428,23 @@ class TestChatWidgetService extends mock() { class TestCommandService extends mock() { readonly acceptedInputs: string[] = []; + readonly acceptedOmniInputs: string[] = []; + + constructor(private readonly omniFocused = false) { + super(); + } override async executeCommand(commandId: string, ...args: unknown[]): Promise { - let result: string | undefined; + let result: string | boolean | undefined; if (commandId === '_chat.voice.getCurrentSession') { result = 'chat-session'; } else if (commandId === '_chat.voice.acceptInput' && typeof args[0] === 'string') { this.acceptedInputs.push(args[0]); + } else if (commandId === '_chat.omni.acceptVoiceInput' && typeof args[0] === 'string') { + if (this.omniFocused) { + this.acceptedOmniInputs.push(args[0]); + } + result = this.omniFocused; } return result as T; } @@ -512,9 +521,6 @@ suite('VoiceSessionController', () => { new TestChatWidgetService(), notificationService, promptsService, - new class extends mock() { - override readonly onDidResolveRoute = Event.None; - }(), )); } @@ -4228,6 +4234,34 @@ suite('VoiceSessionController', () => { assert.deepStrictEqual(commandService.acceptedInputs, ['send this when listening stops']); }); + test('focused omni chat routes voice input instead of the panel session', async () => { + const voiceClientService = new TestVoiceClientService(); + const commandService = new TestCommandService(true); + const controller = createController( + voiceClientService, + undefined, + commandService, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ); + + const sendTranscriptionToChat = Reflect.get(controller, '_sendTranscriptionToChat') as (text: string) => Promise; + await sendTranscriptionToChat.call(controller, 'run the focused omni request'); + + assert.deepStrictEqual({ + omniInputs: commandService.acceptedOmniInputs, + panelInputs: commandService.acceptedInputs, + }, { + omniInputs: ['run the focused omni request'], + panelInputs: [], + }); + }); + test('auto-listen is skipped when window does not have focus (multi-window hands-free)', () => { const voiceClientService = new TestVoiceClientService(); const mic = new RecordingMicCaptureService(); @@ -4534,10 +4568,6 @@ suite('VoiceSessionController live transcription', () => { onDidChangeFocusedSession: Event.None, getAllWidgets: () => [], }); - instantiationService.stub(IChatInputWindowService, { - onDidResolveRoute: Event.None, - }); - const controller = store.add(instantiationService.createInstance(VoiceSessionController)); controller['_isConnected'].set(true, undefined); controller['_userLogin'] = 'test-user'; From b8dab5611d504a3ae7bb3ed0213bd24314e9bee1 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 16:40:40 -0400 Subject: [PATCH 29/37] Restore omni chat opening lifecycle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11adb7d-72c1-4e64-ba1e-f798dd9bf490 --- .../chatInputWindow.contribution.ts | 17 +++++++++++++++++ .../chatInputWindow/chatInputWindowService.ts | 8 +++----- .../contrib/chat/common/chatInputWindow.ts | 7 +++++++ 3 files changed, 27 insertions(+), 5 deletions(-) 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 7fcec1f26649a1..5020dc0fa2fd00 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts @@ -5,7 +5,9 @@ import * as nls from '../../../../../nls.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; @@ -22,6 +24,20 @@ const inputWindowEnabled = ContextKeyExpr.and( ContextKeyExpr.equals(`config.${OmniChatEnabledSettingId}`, true) ); +CommandsRegistry.registerCommand('_chat.omni.acceptVoiceInput', (accessor, text: string) => { + return accessor.get(IChatInputWindowService).acceptVoiceInput(text); +}); + +let voiceRoutingBridge: IDisposable | undefined; +function ensureVoiceRoutingBridge(accessor: ServicesAccessor, service: IChatInputWindowService): void { + if (!voiceRoutingBridge) { + const commandService = accessor.get(ICommandService); + voiceRoutingBridge = service.onDidResolveRoute(({ resource, kind }) => { + commandService.executeCommand('_chat.voice.setOmniTarget', resource?.toString(), kind).catch(() => { }); + }); + } +} + registerAction2(class extends Action2 { constructor() { super({ @@ -49,6 +65,7 @@ registerAction2(class extends Action2 { } async run(accessor: ServicesAccessor): Promise { const chatInputWindowService = accessor.get(IChatInputWindowService); + ensureVoiceRoutingBridge(accessor, chatInputWindowService); 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 index 4dd95709be5baf..d009594ba9fc3f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -16,7 +16,6 @@ import { InstantiationType, registerSingleton } from '../../../../../platform/in 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 { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.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'; @@ -49,6 +48,8 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private readonly _onDidChangeOpen = this._register(new Emitter()); readonly onDidChangeOpen: Event = this._onDidChangeOpen.event; + private readonly _onDidResolveRoute = this._register(new Emitter<{ resource: URI | undefined; kind?: 'existing_session' | 'new_session' }>()); + readonly onDidResolveRoute = this._onDidResolveRoute.event; private readonly _auxiliaryWindowRef = this._register(new MutableDisposable()); private _window: IAuxiliaryWindow | undefined; @@ -80,12 +81,9 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IChatService private readonly chatService: IChatService, - @ICommandService private readonly commandService: ICommandService, ) { super(); - this._register(CommandsRegistry.registerCommand('_chat.omni.acceptVoiceInput', (_accessor, text: string) => this.acceptVoiceInput(text))); - const ownershipChannel = new BroadcastChannel('chat-input-window-ownership'); ownershipChannel.onmessage = (e) => { if (e.data?.type === 'claim' && this._window) { @@ -328,7 +326,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind widget, getOwnSessionResource: () => this._modelRef?.object.sessionResource, onDidResolveRoute: (resource, kind) => { - this.commandService.executeCommand('_chat.voice.setOmniTarget', resource?.toString(), kind).catch(() => { }); + this._onDidResolveRoute.fire({ resource, kind }); }, placeBadge: (badge) => { const container = this._window?.container; diff --git a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts index 7a5a2c11d2a581..233e957cf90e88 100644 --- a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts +++ b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../base/common/event.js'; +import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; export const CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID = 'workbench.action.chat.toggleInputWindow'; @@ -35,6 +36,12 @@ export interface IChatInputWindowService { */ readonly onDidChangeOpen: Event; + /** Fires when omni resolves a single voice routing target. */ + readonly onDidResolveRoute: Event<{ resource: URI | undefined; kind?: 'existing_session' | 'new_session' }>; + + /** Routes voice input through omni when its auxiliary window owns focus. */ + acceptVoiceInput(text: string): Promise; + /** * Opens the floating chat input window. No-op if already open. */ From 093e9fab68a184d1f805def17cd631c0c1bb687e Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 16:44:04 -0400 Subject: [PATCH 30/37] Add omni chat opening diagnostics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11adb7d-72c1-4e64-ba1e-f798dd9bf490 --- .../chatInputWindow.contribution.ts | 2 ++ .../chatInputWindow/chatInputWindowService.ts | 27 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) 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 5020dc0fa2fd00..e4bd657841eb33 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts @@ -11,6 +11,7 @@ import { CommandsRegistry, ICommandService } from '../../../../../platform/comma import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID, IChatInputWindowService } from '../../common/chatInputWindow.js'; import { OmniChatEnabledSettingId } from '../../common/sessionRouter.js'; @@ -64,6 +65,7 @@ registerAction2(class extends Action2 { }); } async run(accessor: ServicesAccessor): Promise { + accessor.get(ILogService).info('[chatInputWindow] toggle command invoked'); const chatInputWindowService = accessor.get(IChatInputWindowService); ensureVoiceRoutingBridge(accessor, chatInputWindowService); 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 index d009594ba9fc3f..ed90f9dbabd27c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -17,6 +17,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ILogService } from '../../../../../platform/log/common/log.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'; @@ -81,8 +82,10 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IChatService private readonly chatService: IChatService, + @ILogService private readonly logService: ILogService, ) { super(); + this.logService.info('[chatInputWindow] service constructed'); const ownershipChannel = new BroadcastChannel('chat-input-window-ownership'); ownershipChannel.onmessage = (e) => { @@ -106,6 +109,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } async openWindow(): Promise { + this.logService.info(`[chatInputWindow] open requested existing=${Boolean(this._window)} pending=${Boolean(this._openOperation)}`); if (this._window) { return; } @@ -116,6 +120,15 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind this._openOperation = this._doOpenWindow(); try { await this._openOperation; + this.logService.info('[chatInputWindow] open completed'); + } catch (error) { + this.logService.error('[chatInputWindow] open failed', error); + this._disposeWidget(); + this._window = undefined; + this._windowDisposables.clear(); + this._auxiliaryWindowRef.clear(); + this.storageService.store(ChatInputWindowStorageKeys.WindowOpen, false, StorageScope.WORKSPACE, StorageTarget.MACHINE); + throw error; } finally { this._openOperation = undefined; } @@ -126,6 +139,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind // so the input window is centered on it rather than always the main one. this._invokingWindow = dom.getActiveWindow(); const bounds = this._defaultBounds(); + this.logService.info(`[chatInputWindow] opening auxiliary window bounds=${bounds.x},${bounds.y},${bounds.width}x${bounds.height}`); const auxiliaryWindow = await this.auxiliaryWindowService.open({ bounds, @@ -137,6 +151,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind noBackgroundThrottling: true, backgroundColor: '#00000000', }); + this.logService.info('[chatInputWindow] auxiliary window created'); this._window = auxiliaryWindow; this._auxiliaryWindowRef.value = auxiliaryWindow; @@ -187,7 +202,9 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind // compact ChatWidget. The response list is filtered out so only the input // box shows. Submission is intercepted via submitHandler (the routing // seam) and routed to the best-matching existing session. + this.logService.info('[chatInputWindow] rendering chat widget'); this._renderChatWidget(auxiliaryWindow, row); + this.logService.info('[chatInputWindow] chat widget rendered'); const trail = dom.append(row, dom.$('.chat-input-window-trail')); this._trail = trail; @@ -239,6 +256,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } async toggleWindow(): Promise { + this.logService.info(`[chatInputWindow] toggle requested open=${this.isOpen}`); if (this.isOpen) { this.closeWindow(); } else { @@ -310,12 +328,15 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } )); this._widget = widget; + this.logService.info('[chatInputWindow] chat widget created'); widget.render(parent); widget.setVisible(true); + this.logService.info('[chatInputWindow] chat widget visible'); const modelRef = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { disableBackgroundKeepAlive: true, debugOwner: 'ChatInputWindow' }); this._modelRef = modelRef; widget.setModel(modelRef.object); + this.logService.info(`[chatInputWindow] scratch model set resource=${modelRef.object.sessionResource.toString()}`); let fitWindowToInput = () => { }; @@ -353,6 +374,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind }, }; this._routingController = this._windowDisposables.add(this.instantiationService.createInstance(ChatSessionRoutingController, host, 'chatInputWindow')); + this.logService.info('[chatInputWindow] routing controller created'); // Fit the frameless window to the widget's own content and any routing // panel below it. Measuring the input container itself includes the @@ -384,7 +406,10 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind x = Math.round(invokingWindow.screenX + (invokingWindow.outerWidth - win.outerWidth) / 2); y = Math.round(invokingWindow.screenY + (invokingWindow.outerHeight - contentHeight) / 2); } - void auxiliaryWindow.setBounds({ x, y, width: win.outerWidth, height: contentHeight }); + this.logService.info(`[chatInputWindow] fitting auxiliary window bounds=${x},${y},${win.outerWidth}x${contentHeight}`); + auxiliaryWindow.setBounds({ x, y, width: win.outerWidth, height: contentHeight }).catch(error => { + this.logService.error('[chatInputWindow] failed to fit auxiliary window', error); + }); }; let layingOut = false; From 67b721607bbe8e7a8b9d15e1780910b06d5587e9 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 16:46:38 -0400 Subject: [PATCH 31/37] Keep omni focus ring within bounds Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11adb7d-72c1-4e64-ba1e-f798dd9bf490 --- .../chat/browser/chatInputWindow/media/chatInputWindow.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css b/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css index f68bd42d4217ad..30ab52aa71ad97 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css @@ -13,8 +13,7 @@ } .chat-input-window:focus-within { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; + box-shadow: inset 0 0 0 1px var(--vscode-focusBorder); } .chat-input-window-body { From d4646d4dd215d3d8c6cd9b08a81d81afb9a209c6 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 16:48:52 -0400 Subject: [PATCH 32/37] Stabilize omni chat opening layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11adb7d-72c1-4e64-ba1e-f798dd9bf490 --- src/vs/workbench/contrib/chat/browser/chat.ts | 1 + .../chatInputWindow.contribution.ts | 2 -- .../chatInputWindow/chatInputWindowService.ts | 30 ++++++------------- .../contrib/chat/browser/widget/chatWidget.ts | 4 +++ 4 files changed, 14 insertions(+), 23 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 94a8c91666a6cb..61c642ecd28d52 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -256,6 +256,7 @@ export interface IChatWidgetViewOptions { renderFollowups?: boolean; renderStyle?: 'compact' | 'minimal'; renderInputToolbarBelowInput?: boolean; + renderGettingStartedTip?: boolean; supportsFileReferences?: boolean; filter?: (item: ChatTreeItem) => boolean; /** 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 e4bd657841eb33..5020dc0fa2fd00 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts @@ -11,7 +11,6 @@ import { CommandsRegistry, ICommandService } from '../../../../../platform/comma import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID, IChatInputWindowService } from '../../common/chatInputWindow.js'; import { OmniChatEnabledSettingId } from '../../common/sessionRouter.js'; @@ -65,7 +64,6 @@ registerAction2(class extends Action2 { }); } async run(accessor: ServicesAccessor): Promise { - accessor.get(ILogService).info('[chatInputWindow] toggle command invoked'); const chatInputWindowService = accessor.get(IChatInputWindowService); ensureVoiceRoutingBridge(accessor, chatInputWindowService); 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 index ed90f9dbabd27c..937617f62fe01c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -17,7 +17,6 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { ILogService } from '../../../../../platform/log/common/log.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'; @@ -82,10 +81,8 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IChatService private readonly chatService: IChatService, - @ILogService private readonly logService: ILogService, ) { super(); - this.logService.info('[chatInputWindow] service constructed'); const ownershipChannel = new BroadcastChannel('chat-input-window-ownership'); ownershipChannel.onmessage = (e) => { @@ -109,7 +106,6 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } async openWindow(): Promise { - this.logService.info(`[chatInputWindow] open requested existing=${Boolean(this._window)} pending=${Boolean(this._openOperation)}`); if (this._window) { return; } @@ -120,9 +116,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind this._openOperation = this._doOpenWindow(); try { await this._openOperation; - this.logService.info('[chatInputWindow] open completed'); } catch (error) { - this.logService.error('[chatInputWindow] open failed', error); this._disposeWidget(); this._window = undefined; this._windowDisposables.clear(); @@ -139,7 +133,6 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind // so the input window is centered on it rather than always the main one. this._invokingWindow = dom.getActiveWindow(); const bounds = this._defaultBounds(); - this.logService.info(`[chatInputWindow] opening auxiliary window bounds=${bounds.x},${bounds.y},${bounds.width}x${bounds.height}`); const auxiliaryWindow = await this.auxiliaryWindowService.open({ bounds, @@ -151,7 +144,6 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind noBackgroundThrottling: true, backgroundColor: '#00000000', }); - this.logService.info('[chatInputWindow] auxiliary window created'); this._window = auxiliaryWindow; this._auxiliaryWindowRef.value = auxiliaryWindow; @@ -202,9 +194,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind // compact ChatWidget. The response list is filtered out so only the input // box shows. Submission is intercepted via submitHandler (the routing // seam) and routed to the best-matching existing session. - this.logService.info('[chatInputWindow] rendering chat widget'); this._renderChatWidget(auxiliaryWindow, row); - this.logService.info('[chatInputWindow] chat widget rendered'); const trail = dom.append(row, dom.$('.chat-input-window-trail')); this._trail = trail; @@ -256,7 +246,6 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } async toggleWindow(): Promise { - this.logService.info(`[chatInputWindow] toggle requested open=${this.isOpen}`); if (this.isOpen) { this.closeWindow(); } else { @@ -307,6 +296,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind autoScroll: true, renderInputOnTop: true, renderStyle: 'compact', + renderGettingStartedTip: false, // Show only the input box — drop every response list item. filter: () => false, enableImplicitContext: false, @@ -328,15 +318,12 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } )); this._widget = widget; - this.logService.info('[chatInputWindow] chat widget created'); widget.render(parent); widget.setVisible(true); - this.logService.info('[chatInputWindow] chat widget visible'); const modelRef = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { disableBackgroundKeepAlive: true, debugOwner: 'ChatInputWindow' }); this._modelRef = modelRef; widget.setModel(modelRef.object); - this.logService.info(`[chatInputWindow] scratch model set resource=${modelRef.object.sessionResource.toString()}`); let fitWindowToInput = () => { }; @@ -374,7 +361,6 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind }, }; this._routingController = this._windowDisposables.add(this.instantiationService.createInstance(ChatSessionRoutingController, host, 'chatInputWindow')); - this.logService.info('[chatInputWindow] routing controller created'); // Fit the frameless window to the widget's own content and any routing // panel below it. Measuring the input container itself includes the @@ -389,6 +375,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind if (!win || win !== auxiliaryWindow.window) { return; } + const width = Math.max(this._defaultWidth(), win.outerWidth); const rowHeight = Math.max(CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT, Math.ceil(widget.contentHeight)); const extraHeight = Array.from(auxiliaryWindow.container.children) .filter(child => child !== this._row) @@ -403,13 +390,10 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind if (!didInitialPosition) { didInitialPosition = true; const invokingWindow = this._invokingWindow; - x = Math.round(invokingWindow.screenX + (invokingWindow.outerWidth - win.outerWidth) / 2); + x = Math.round(invokingWindow.screenX + (invokingWindow.outerWidth - width) / 2); y = Math.round(invokingWindow.screenY + (invokingWindow.outerHeight - contentHeight) / 2); } - this.logService.info(`[chatInputWindow] fitting auxiliary window bounds=${x},${y},${win.outerWidth}x${contentHeight}`); - auxiliaryWindow.setBounds({ x, y, width: win.outerWidth, height: contentHeight }).catch(error => { - this.logService.error('[chatInputWindow] failed to fit auxiliary window', error); - }); + void auxiliaryWindow.setBounds({ x, y, width, height: contentHeight }); }; let layingOut = false; @@ -508,7 +492,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind // Match Quick Chat's width so the model-detail hover has room to sit // beside the picker: golden-cut of the invoking window, capped like the // quick input widget (MAX_WIDTH = 600). - const width = Math.round(Math.min(invokingWindow.innerWidth * 0.62, 600)); + const width = this._defaultWidth(); // Center the omni bar within the window that invoked it. const x = Math.round(invokingWindow.screenX + (invokingWindow.outerWidth - width) / 2); const y = Math.round(invokingWindow.screenY + (invokingWindow.outerHeight - CHAT_INPUT_WINDOW_DEFAULT_HEIGHT) / 2); @@ -519,6 +503,10 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind height: CHAT_INPUT_WINDOW_DEFAULT_HEIGHT, }; } + + private _defaultWidth(): number { + return Math.round(Math.min(this._invokingWindow.innerWidth * 0.62, 600)); + } } registerSingleton(IChatInputWindowService, ChatInputWindowService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index bafc8790ab0249..5df91f7aa341a6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1223,6 +1223,10 @@ export class ChatWidget extends Disposable implements IChatWidget { } private renderGettingStartedTipIfNeeded(): void { + if (this.viewOptions.renderGettingStartedTip === false) { + this.clearGettingStartedTip(); + return; + } if (!this.inputPart || !this.viewModel) { return; } From c47ce4f66e38e0b4985a2c42fe982d11f3aa99ec Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 16:52:47 -0400 Subject: [PATCH 33/37] Center omni in the invoking window Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11adb7d-72c1-4e64-ba1e-f798dd9bf490 --- .../chatInputWindow.contribution.ts | 10 ++++- .../chatInputWindow/chatInputWindowService.ts | 39 +++++++++++-------- .../contrib/chat/common/chatInputWindow.ts | 5 ++- 3 files changed, 35 insertions(+), 19 deletions(-) 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 5020dc0fa2fd00..e3e2ad73d06083 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as nls from '../../../../../nls.js'; +import * as dom from '../../../../../base/browser/dom.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; @@ -64,9 +65,16 @@ registerAction2(class extends Action2 { }); } async run(accessor: ServicesAccessor): Promise { + const invokingWindow = dom.getActiveWindow(); + const invokingWindowBounds = { + x: invokingWindow.screenX, + y: invokingWindow.screenY, + width: invokingWindow.outerWidth, + height: invokingWindow.outerHeight, + }; const chatInputWindowService = accessor.get(IChatInputWindowService); ensureVoiceRoutingBridge(accessor, chatInputWindowService); - await chatInputWindowService.toggleWindow(); + await chatInputWindowService.toggleWindow(invokingWindowBounds); } }); diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index 937617f62fe01c..ee0803ebeb5dc2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -11,7 +11,7 @@ import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '.. import { AnchorPosition } from '../../../../../base/common/layout.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; -import { CodeWindow, mainWindow } from '../../../../../base/browser/window.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'; @@ -66,8 +66,8 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind /** In-flight `openWindow()` operation, so concurrent toggles stay idempotent. */ private _openOperation: Promise | undefined; private _actionWidgetRestoreHeight: number | undefined; - /** The window that invoked the input window; used to center it on that window. */ - private _invokingWindow: CodeWindow = mainWindow; + /** Immutable bounds of the window that invoked omni, captured before service resolution. */ + private _invokingWindowBounds: IRectangle = this._windowBounds(mainWindow); get isOpen(): boolean { return !!this._window; @@ -105,10 +105,11 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } } - async openWindow(): Promise { + async openWindow(invokingWindowBounds?: IRectangle): Promise { if (this._window) { return; } + this._invokingWindowBounds = invokingWindowBounds ?? this._windowBounds(dom.getActiveWindow()); // Coalesce concurrent open/toggle calls so we never create two aux windows. if (this._openOperation) { return this._openOperation; @@ -129,9 +130,6 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } private async _doOpenWindow(): Promise { - // Capture the window that invoked us (before the aux window steals focus) - // so the input window is centered on it rather than always the main one. - this._invokingWindow = dom.getActiveWindow(); const bounds = this._defaultBounds(); const auxiliaryWindow = await this.auxiliaryWindowService.open({ @@ -245,12 +243,12 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind this._onDidChangeOpen.fire(false); } - async toggleWindow(): Promise { + async toggleWindow(invokingWindowBounds?: IRectangle): Promise { if (this.isOpen) { this.closeWindow(); } else { this._ownershipChannel.postMessage({ type: 'claim' }); - await this.openWindow(); + await this.openWindow(invokingWindowBounds); } } @@ -389,9 +387,9 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind let y = win.screenY; if (!didInitialPosition) { didInitialPosition = true; - const invokingWindow = this._invokingWindow; - x = Math.round(invokingWindow.screenX + (invokingWindow.outerWidth - width) / 2); - y = Math.round(invokingWindow.screenY + (invokingWindow.outerHeight - contentHeight) / 2); + const invokingWindowBounds = this._invokingWindowBounds; + x = Math.round(invokingWindowBounds.x + (invokingWindowBounds.width - width) / 2); + y = Math.round(invokingWindowBounds.y + (invokingWindowBounds.height - contentHeight) / 2); } void auxiliaryWindow.setBounds({ x, y, width, height: contentHeight }); }; @@ -488,14 +486,14 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } private _defaultBounds(): IRectangle { - const invokingWindow = this._invokingWindow; + const invokingWindowBounds = this._invokingWindowBounds; // Match Quick Chat's width so the model-detail hover has room to sit // beside the picker: golden-cut of the invoking window, capped like the // quick input widget (MAX_WIDTH = 600). const width = this._defaultWidth(); // Center the omni bar within the window that invoked it. - const x = Math.round(invokingWindow.screenX + (invokingWindow.outerWidth - width) / 2); - const y = Math.round(invokingWindow.screenY + (invokingWindow.outerHeight - CHAT_INPUT_WINDOW_DEFAULT_HEIGHT) / 2); + const x = Math.round(invokingWindowBounds.x + (invokingWindowBounds.width - width) / 2); + const y = Math.round(invokingWindowBounds.y + (invokingWindowBounds.height - CHAT_INPUT_WINDOW_DEFAULT_HEIGHT) / 2); return { x, y, @@ -505,7 +503,16 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } private _defaultWidth(): number { - return Math.round(Math.min(this._invokingWindow.innerWidth * 0.62, 600)); + return Math.round(Math.min(this._invokingWindowBounds.width * 0.62, 600)); + } + + private _windowBounds(window: Window): IRectangle { + return { + x: window.screenX, + y: window.screenY, + width: window.outerWidth, + height: window.outerHeight, + }; } } diff --git a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts index 233e957cf90e88..40670d06486937 100644 --- a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts +++ b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts @@ -6,6 +6,7 @@ import { Event } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IRectangle } from '../../../../platform/window/common/window.js'; export const CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID = 'workbench.action.chat.toggleInputWindow'; @@ -45,7 +46,7 @@ export interface IChatInputWindowService { /** * Opens the floating chat input window. No-op if already open. */ - openWindow(): Promise; + openWindow(invokingWindowBounds?: IRectangle): Promise; /** * Closes the floating chat input window. No-op if already closed. @@ -55,5 +56,5 @@ export interface IChatInputWindowService { /** * Toggles the floating chat input window open/closed. */ - toggleWindow(): Promise; + toggleWindow(invokingWindowBounds?: IRectangle): Promise; } From 6c623702a7408d82e893752d67f385be2c026202 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 16:53:41 -0400 Subject: [PATCH 34/37] Filter low-confidence omni routes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11adb7d-72c1-4e64-ba1e-f798dd9bf490 --- .../browser/sessionRouter/chatSessionRoutingController.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts index 18b3c786c145e6..8674984f540838 100644 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts @@ -33,7 +33,7 @@ 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; +const ROUTE_CONFIDENCE_THRESHOLD = 0.65; /** * When the last-used session is within this confidence margin of the top match, @@ -235,7 +235,7 @@ export class ChatSessionRoutingController extends Disposable { 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) { + if (!top || top.confidence <= ROUTE_CONFIDENCE_THRESHOLD) { return { kind: 'new', label: localize('chatSessionRouting.newSession', "New session") }; } @@ -244,7 +244,7 @@ export class ChatSessionRoutingController extends Disposable { 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 + && r.confidence > ROUTE_CONFIDENCE_THRESHOLD && (top.confidence - r.confidence) <= ROUTE_AMBIGUITY_MARGIN) : undefined; const chosen = preferred ?? top; @@ -422,7 +422,7 @@ export class ChatSessionRoutingController extends Disposable { const labelById = new Map(candidates.map(candidate => [candidate.sessionId, candidate.label])); const ranked = results - .filter(result => labelById.has(result.sessionId)) + .filter(result => result.confidence > ROUTE_CONFIDENCE_THRESHOLD && labelById.has(result.sessionId)) .sort((a, b) => b.confidence - a.confidence) .slice(0, ROUTE_MAX_CHOICES) .map(result => ({ From 0fdabdd6592271a509e7529f62d43bbef90c5fce Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 17:26:10 -0400 Subject: [PATCH 35/37] fix check --- .../chat/browser/chatInputWindow/chatInputWindowService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index ee0803ebeb5dc2..8418dcdb2f1175 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -12,6 +12,7 @@ import { AnchorPosition } from '../../../../../base/common/layout.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { mainWindow } from '../../../../../base/browser/window.js'; +import { URI } from '../../../../../base/common/uri.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'; From 46a16fd2228eaa04ed19aa3bd5365546b97aa53e Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 17:34:38 -0400 Subject: [PATCH 36/37] push changes --- .../chatInputWindow.contribution.ts | 18 +++-------------- .../chatInputWindow/chatInputWindowService.ts | 20 ++++++++++++------- .../chatSessionRoutingController.ts | 14 ++++--------- .../voiceClient/voiceSessionController.ts | 5 +++-- .../contrib/chat/common/chatInputWindow.ts | 6 ++---- .../contrib/chat/common/sessionRouter.ts | 7 +++++++ .../voiceSessionController.test.ts | 3 ++- .../chat/test/common/sessionRouter.test.ts | 10 +++++++++- 8 files changed, 43 insertions(+), 40 deletions(-) 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 e3e2ad73d06083..36fd87c418be69 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts @@ -6,14 +6,13 @@ import * as nls from '../../../../../nls.js'; import * as dom from '../../../../../base/browser/dom.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.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 { CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID, IChatInputWindowService } from '../../common/chatInputWindow.js'; +import { CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID, CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID, IChatInputWindowService } from '../../common/chatInputWindow.js'; import { OmniChatEnabledSettingId } from '../../common/sessionRouter.js'; import { ChatViewId } from '../chat.js'; @@ -25,20 +24,10 @@ const inputWindowEnabled = ContextKeyExpr.and( ContextKeyExpr.equals(`config.${OmniChatEnabledSettingId}`, true) ); -CommandsRegistry.registerCommand('_chat.omni.acceptVoiceInput', (accessor, text: string) => { +CommandsRegistry.registerCommand(CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID, (accessor, text: string) => { return accessor.get(IChatInputWindowService).acceptVoiceInput(text); }); -let voiceRoutingBridge: IDisposable | undefined; -function ensureVoiceRoutingBridge(accessor: ServicesAccessor, service: IChatInputWindowService): void { - if (!voiceRoutingBridge) { - const commandService = accessor.get(ICommandService); - voiceRoutingBridge = service.onDidResolveRoute(({ resource, kind }) => { - commandService.executeCommand('_chat.voice.setOmniTarget', resource?.toString(), kind).catch(() => { }); - }); - } -} - registerAction2(class extends Action2 { constructor() { super({ @@ -73,7 +62,6 @@ registerAction2(class extends Action2 { height: invokingWindow.outerHeight, }; const chatInputWindowService = accessor.get(IChatInputWindowService); - ensureVoiceRoutingBridge(accessor, chatInputWindowService); await chatInputWindowService.toggleWindow(invokingWindowBounds); } }); diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts index 8418dcdb2f1175..333b47c9568b16 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -12,11 +12,11 @@ import { AnchorPosition } from '../../../../../base/common/layout.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { mainWindow } from '../../../../../base/browser/window.js'; -import { URI } from '../../../../../base/common/uri.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 { ICommandService } from '../../../../../platform/commands/common/commands.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'; @@ -32,10 +32,11 @@ import { ChatWidget } from '../widget/chatWidget.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatSessionRoutingController, IChatSessionRoutingHost } from '../sessionRouter/chatSessionRoutingController.js'; import { combineVoiceInput } from '../voiceClient/voiceInputUtils.js'; -import { IChatInputWindowService, ChatInputWindowStorageKeys, CHAT_INPUT_WINDOW_DEFAULT_HEIGHT } from '../../common/chatInputWindow.js'; +import { IChatInputWindowService, ChatInputWindowStorageKeys, CHAT_INPUT_WINDOW_DEFAULT_HEIGHT, CHAT_INPUT_WINDOW_SET_VOICE_TARGET_COMMAND_ID } from '../../common/chatInputWindow.js'; const CHAT_INPUT_WINDOW_MODEL_PICKER_HEIGHT = 420; const CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT = 44; +const CHAT_INPUT_WINDOW_MAX_WIDTH = 600; /** * Hosts a frameless, always-on-top auxiliary window containing the full chat @@ -49,8 +50,6 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind private readonly _onDidChangeOpen = this._register(new Emitter()); readonly onDidChangeOpen: Event = this._onDidChangeOpen.event; - private readonly _onDidResolveRoute = this._register(new Emitter<{ resource: URI | undefined; kind?: 'existing_session' | 'new_session' }>()); - readonly onDidResolveRoute = this._onDidResolveRoute.event; private readonly _auxiliaryWindowRef = this._register(new MutableDisposable()); private _window: IAuxiliaryWindow | undefined; @@ -82,6 +81,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IChatService private readonly chatService: IChatService, + @ICommandService private readonly commandService: ICommandService, ) { super(); @@ -110,11 +110,11 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind if (this._window) { return; } - this._invokingWindowBounds = invokingWindowBounds ?? this._windowBounds(dom.getActiveWindow()); // Coalesce concurrent open/toggle calls so we never create two aux windows. if (this._openOperation) { return this._openOperation; } + this._invokingWindowBounds = invokingWindowBounds ?? this._windowBounds(dom.getActiveWindow()); this._openOperation = this._doOpenWindow(); try { await this._openOperation; @@ -333,7 +333,7 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind widget, getOwnSessionResource: () => this._modelRef?.object.sessionResource, onDidResolveRoute: (resource, kind) => { - this._onDidResolveRoute.fire({ resource, kind }); + this.commandService.executeCommand(CHAT_INPUT_WINDOW_SET_VOICE_TARGET_COMMAND_ID, resource?.toString(), kind).catch(() => { }); }, placeBadge: (badge) => { const container = this._window?.container; @@ -504,7 +504,13 @@ export class ChatInputWindowService extends Disposable implements IChatInputWind } private _defaultWidth(): number { - return Math.round(Math.min(this._invokingWindowBounds.width * 0.62, 600)); + const invokingWindowWidth = this._invokingWindowBounds.width > 0 + ? this._invokingWindowBounds.width + : mainWindow.outerWidth; + const availableWidth = invokingWindowWidth > 0 + ? invokingWindowWidth + : CHAT_INPUT_WINDOW_MAX_WIDTH / 0.62; + return Math.round(Math.min(availableWidth * 0.62, CHAT_INPUT_WINDOW_MAX_WIDTH)); } private _windowBounds(window: Window): IRectangle { diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts index 8674984f540838..13eefbe926985c 100644 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts @@ -20,7 +20,7 @@ import { IChatRequestVariableEntry } from '../../common/attachments/chatVariable import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { IChatModelReference, IChatService } from '../../common/chatService/chatService.js'; import { IChatSessionHistoryItem, IChatSessionsService } from '../../common/chatSessionsService.js'; -import { heuristicScore, IRoutableSession, ISessionRouteResult, ISessionRouter, ROUTER_FIELD_CLIP_LENGTH } from '../../common/sessionRouter.js'; +import { heuristicScore, IRoutableSession, isHighConfidenceSessionRoute, ISessionRouteResult, ISessionRouter, ROUTER_FIELD_CLIP_LENGTH } from '../../common/sessionRouter.js'; import { AgentSessionProviders } from '../agentSessions/agentSessions.js'; import { IAgentSession, AgentSessionStatus } from '../agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; @@ -29,12 +29,6 @@ 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.65; - /** * 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. @@ -235,7 +229,7 @@ export class ChatSessionRoutingController extends Disposable { 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) { + if (!top || !isHighConfidenceSessionRoute(top)) { return { kind: 'new', label: localize('chatSessionRouting.newSession', "New session") }; } @@ -244,7 +238,7 @@ export class ChatSessionRoutingController extends Disposable { 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 + && isHighConfidenceSessionRoute(r) && (top.confidence - r.confidence) <= ROUTE_AMBIGUITY_MARGIN) : undefined; const chosen = preferred ?? top; @@ -422,7 +416,7 @@ export class ChatSessionRoutingController extends Disposable { const labelById = new Map(candidates.map(candidate => [candidate.sessionId, candidate.label])); const ranked = results - .filter(result => result.confidence > ROUTE_CONFIDENCE_THRESHOLD && labelById.has(result.sessionId)) + .filter(result => isHighConfidenceSessionRoute(result) && labelById.has(result.sessionId)) .sort((a, b) => b.confidence - a.confidence) .slice(0, ROUTE_MAX_CHOICES) .map(result => ({ diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index e119da33472559..79320ad6f90eb8 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -43,6 +43,7 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; +import { CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID, CHAT_INPUT_WINDOW_SET_VOICE_TARGET_COMMAND_ID } from '../../common/chatInputWindow.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { VoiceFirstConnectClassification, VoiceFirstConnectEvent, @@ -773,7 +774,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC ) { super(); - this._register(CommandsRegistry.registerCommand('_chat.voice.setOmniTarget', (_accessor, resource: string | undefined, kind?: 'existing_session' | 'new_session') => { + this._register(CommandsRegistry.registerCommand(CHAT_INPUT_WINDOW_SET_VOICE_TARGET_COMMAND_ID, (_accessor, resource: string | undefined, kind?: 'existing_session' | 'new_session') => { if (this._isConnected.get() || this._isConnecting.get()) { this.setTargetSession(resource ? URI.parse(resource) : undefined, kind); } @@ -3165,7 +3166,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // dictating into, so it takes priority over whichever surface has focus // by the time the backend finalizes the turn. const pinnedTarget = this._consumePinnedSubmitSession(); - const acceptedByOmni = !pinnedTarget && await this.commandService.executeCommand('_chat.omni.acceptVoiceInput', text).catch(() => false); + const acceptedByOmni = !pinnedTarget && await this.commandService.executeCommand(CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID, text).catch(() => false); if (acceptedByOmni) { return; } diff --git a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts index 40670d06486937..7bd6c3bd5cb287 100644 --- a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts +++ b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts @@ -4,11 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../base/common/event.js'; -import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IRectangle } from '../../../../platform/window/common/window.js'; export const CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID = 'workbench.action.chat.toggleInputWindow'; +export const CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID = '_chat.omni.acceptVoiceInput'; +export const CHAT_INPUT_WINDOW_SET_VOICE_TARGET_COMMAND_ID = '_chat.voice.setOmniTarget'; /** * Default height for the floating chat input window. @@ -37,9 +38,6 @@ export interface IChatInputWindowService { */ readonly onDidChangeOpen: Event; - /** Fires when omni resolves a single voice routing target. */ - readonly onDidResolveRoute: Event<{ resource: URI | undefined; kind?: 'existing_session' | 'new_session' }>; - /** Routes voice input through omni when its auxiliary window owns focus. */ acceptVoiceInput(text: string): Promise; diff --git a/src/vs/workbench/contrib/chat/common/sessionRouter.ts b/src/vs/workbench/contrib/chat/common/sessionRouter.ts index 71667c597d725e..2a0b2597687f8b 100644 --- a/src/vs/workbench/contrib/chat/common/sessionRouter.ts +++ b/src/vs/workbench/contrib/chat/common/sessionRouter.ts @@ -12,6 +12,13 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta */ export const OmniChatEnabledSettingId = 'chat.omni.enabled'; +/** Existing sessions must exceed this confidence to be shown or selected. */ +export const SESSION_ROUTE_CONFIDENCE_THRESHOLD = 0.65; + +export function isHighConfidenceSessionRoute(result: ISessionRouteResult): boolean { + return result.confidence > SESSION_ROUTE_CONFIDENCE_THRESHOLD; +} + /** * A session that a user request can be routed to. Populated by the caller from * the session list (e.g. `IChatSessionsService` / `ISessionsService`). diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts index b98d28ce2c6d4c..46e10690a92530 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts @@ -34,6 +34,7 @@ import { IMicCaptureService } from '../../../browser/voiceClient/micCaptureServi import { ITtsPlaybackService } from '../../../browser/voiceClient/ttsPlaybackService.js'; import { IVoiceSessionController, VoiceSessionController } from '../../../browser/voiceClient/voiceSessionController.js'; import { IVoiceToolDispatchService } from '../../../browser/voiceClient/voiceToolDispatchService.js'; +import { CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID } from '../../../common/chatInputWindow.js'; import { ChatSendResult, ElicitationState, IChatConfirmation, IChatSendRequestOptions, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { derivePendingId, IVoiceAudioResponse, IVoiceBargeIn, IVoiceCheckpointNarrationMetadata, IVoiceClientService, IVoiceDispatchResult, IVoiceNarrationAck, IVoiceNarrationSignal, IVoiceSessionContext, IVoiceSpeechStarted, IVoiceToolCall, IVoiceTranscription, peekPendingId, VoiceConfirmationType, VoiceNarrationKind, VOICE_AGENT_PROGRESS_SETTING } from '../../../common/voiceClient/voiceClientService.js'; @@ -440,7 +441,7 @@ class TestCommandService extends mock() { result = 'chat-session'; } else if (commandId === '_chat.voice.acceptInput' && typeof args[0] === 'string') { this.acceptedInputs.push(args[0]); - } else if (commandId === '_chat.omni.acceptVoiceInput' && typeof args[0] === 'string') { + } else if (commandId === CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID && typeof args[0] === 'string') { if (this.omniFocused) { this.acceptedOmniInputs.push(args[0]); } 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 19d1df7cc45601..ed2b6792c901d1 100644 --- a/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { buildRouterMessages, heuristicScore, ISessionRouteRequest, parseRouterResponse, ROUTER_FIELD_CLIP_LENGTH } from '../../common/sessionRouter.js'; +import { buildRouterMessages, heuristicScore, isHighConfidenceSessionRoute, ISessionRouteRequest, parseRouterResponse, ROUTER_FIELD_CLIP_LENGTH } from '../../common/sessionRouter.js'; suite('SessionRouter helpers', () => { @@ -62,6 +62,14 @@ suite('SessionRouter helpers', () => { assert.strictEqual(parseRouterResponse('[{"sessionId":"unknown","confidence":0.5}]', new Set(['s1'])), undefined); }); + test('high-confidence routes must exceed 65 percent', () => { + assert.deepStrictEqual([ + isHighConfidenceSessionRoute({ sessionId: 'below', confidence: 0.64 }), + isHighConfidenceSessionRoute({ sessionId: 'boundary', confidence: 0.65 }), + isHighConfidenceSessionRoute({ sessionId: 'above', confidence: 0.66 }), + ], [false, false, true]); + }); + test('heuristicScore ranks the token-overlapping session first', () => { const ranked = heuristicScore(request); assert.strictEqual(ranked[0].sessionId, 's1'); From 8c4f3c521e3d0ad4b103e835ebb4315ec6571a31 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 31 Jul 2026 17:48:12 -0400 Subject: [PATCH 37/37] Harden omni routing lifecycle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b11adb7d-72c1-4e64-ba1e-f798dd9bf490 --- .../chatSessionRoutingController.ts | 10 +++++-- .../input/modelPicker/modelPickerWidget.ts | 29 +++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts index 13eefbe926985c..7ceaf24b02ae60 100644 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts @@ -5,6 +5,7 @@ import * as dom from '../../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; +import { alert as ariaAlert } from '../../../../../base/browser/ui/aria/aria.js'; import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -170,10 +171,11 @@ export class ChatSessionRoutingController extends Disposable { } // A new submission supersedes any pending badge from a previous one. + this._submitCts.value?.cancel(); 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. + // 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; @@ -495,6 +497,10 @@ export class ChatSessionRoutingController extends Disposable { : localize('chatSessionRouting.sendNowHint', "Enter to send now"); }; renderSelection(); + const initialTarget = options[preselected]; + ariaAlert(initialTarget.kind === 'session' + ? localize('chatSessionRouting.sendingToIn', "Sending to {0} in {1} seconds. Press Escape to cancel.", initialTarget.label, Math.ceil(ROUTE_AUTOSEND_DELAY_MS / 1000)) + : localize('chatSessionRouting.startingNewIn', "Starting a new session in {0} seconds. Press Escape to cancel.", Math.ceil(ROUTE_AUTOSEND_DELAY_MS / 1000))); let remainingSeconds = Math.ceil(ROUTE_AUTOSEND_DELAY_MS / 1000); const renderCountdown = () => { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts index e4e35a7d33f84f..61b306e87d28b0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts @@ -107,6 +107,7 @@ export class ModelPickerWidget extends Disposable { private _activatingAfterTrust = false; private readonly _activatingTimer = this._register(new MutableDisposable()); private readonly _pendingAuxiliaryRelayout = this._register(new MutableDisposable()); + private readonly _activeShowDisposables = this._register(new MutableDisposable()); private _showRequestId = 0; private _domNode: HTMLElement | undefined; @@ -415,6 +416,7 @@ export class ModelPickerWidget extends Disposable { } if (this._nameButton?.getAttribute('aria-expanded') === 'true') { this._showRequestId++; + this._activeShowDisposables.clear(); this._nameButton.setAttribute('aria-expanded', 'false'); this._delegate.onDidChangeVisibility?.(false); this._actionWidgetService.hide(true); @@ -496,6 +498,9 @@ export class ModelPickerWidget extends Disposable { // picker is hidden. The ActionListWidget only tracks the disposable for the // currently-shown hover; all other items' hover disposables would leak. const hoverDisposables = new DisposableStore(); + const showDisposables = new DisposableStore(); + showDisposables.add(hoverDisposables); + this._activeShowDisposables.value = showDisposables; for (const item of items) { if (item.hover?.disposable) { hoverDisposables.add(item.hover.disposable); @@ -544,7 +549,11 @@ export class ModelPickerWidget extends Disposable { }, onHide: () => { this._showRequestId++; - hoverDisposables.dispose(); + if (this._activeShowDisposables.value === showDisposables) { + this._activeShowDisposables.clear(); + } else { + showDisposables.dispose(); + } this._nameButton?.setAttribute('aria-expanded', 'false'); this._delegate.onDidChangeVisibility?.(false); if (dom.isHTMLElement(previouslyFocusedElement)) { @@ -557,6 +566,9 @@ export class ModelPickerWidget extends Disposable { const showRequestId = ++this._showRequestId; const showActionWidget = () => { if (showRequestId !== this._showRequestId || this._nameButton?.getAttribute('aria-expanded') !== 'true') { + if (this._activeShowDisposables.value === showDisposables) { + this._activeShowDisposables.clear(); + } return; } this._actionWidgetService.show( @@ -578,12 +590,25 @@ export class ModelPickerWidget extends Disposable { }; const visibilityChange = this._delegate.onDidChangeVisibility?.(true); if (visibilityChange) { - void visibilityChange.then(showActionWidget); + void visibilityChange.then(showActionWidget, () => { + if (this._activeShowDisposables.value === showDisposables) { + this._activeShowDisposables.clear(); + } + }); } else { showActionWidget(); } } + override dispose(): void { + this._showRequestId++; + this._activeShowDisposables.clear(); + if (this._nameButton?.getAttribute('aria-expanded') === 'true') { + this._actionWidgetService.hide(true); + } + super.dispose(); + } + private _updateBadge(): void { if (this._badgeIcon) { if (this._badge) {