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 9345ef599e1ba9..2e07c0b25d2cb5 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'; @@ -92,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'; @@ -242,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."), @@ -2738,6 +2746,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); @@ -2839,6 +2848,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/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 6221b6a44dc87d..77266c4daa6ae4 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'; @@ -307,8 +308,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 new file mode 100644 index 00000000000000..836ed3196b98f8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.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 * as nls from '../../../../../nls.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'; +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(); + } +}); + +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 new file mode 100644 index 00000000000000..efcc324abf3dc7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -0,0 +1,345 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { 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 { localize } from '../../../../../nls.js'; +import { ChatAgentLocation } from '../../common/constants.js'; +import { ChatMode } from '../../common/chatModes.js'; +import { IChatModelReference, IChatService } from '../../common/chatService/chatService.js'; +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'; + +/** 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 + * 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 { + + 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 _modelRef: IChatModelReference | undefined; + /** Parent element hosting the input widget; the routing badge is inserted just before it. */ + private _widgetParent: 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; + + 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, + ) { + 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; + + this._register(dom.addDisposableListener(mainWindow, 'beforeunload', () => { + if (this._window) { + this.closeWindow(); + } + })); + + 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; + } + // 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({ + 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 + ? 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'); + + 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 routed to the best-matching existing session. + this._renderChatWidget(auxiliaryWindow); + + // 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(); + 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); + + // Cancel any in-flight submission so routing can't dispatch after close. + this._routingController?.cancelPending(); + + 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.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 + // 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, + scopedContextKeyService, + ]) + )); + + 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.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._routingController?.handleSubmit(query, mode, attachedContext) ?? Promise.resolve(false), + }, + { + 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); + + // 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); + } + }, + // 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')); + + const layout = () => widget.layout(parent.offsetHeight, parent.offsetWidth); + layout(); + this._windowDisposables.add(dom.addDisposableListener(auxiliaryWindow.window, 'resize', layout)); + this._windowDisposables.add(widget.onDidChangeHeight(() => layout())); + } + + private _disposeWidget(): void { + this._routingController = undefined; + this._widgetParent = undefined; + this._preExpandHeight = undefined; + this._modelRef?.dispose(); + this._modelRef = undefined; + } + + 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); + 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..c1843a0c01843f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts @@ -0,0 +1,648 @@ +/*--------------------------------------------------------------------------------------------- + * 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 = 10000; + +/** + * 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'; + +/** 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; + /** + * 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. + */ +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); + } + + 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 + * 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(); + 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 + // "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); + } + + /** + * 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' })); + 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/sessionRouter/media/chatSessionRouting.css b/src/vs/workbench/contrib/chat/browser/sessionRouter/media/chatSessionRouting.css new file mode 100644 index 00000000000000..da0465492d7815 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/media/chatSessionRouting.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-routing-badge { + 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-routing-badge .chat-routing-badge-label { + flex: 1 1 auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-routing-badge .chat-routing-badge-countdown { + color: var(--vscode-descriptionForeground); + font-variant-numeric: tabular-nums; +} + +.chat-routing-badge .chat-routing-badge-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-routing-badge .chat-routing-badge-action:hover { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; +} + +.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/sessionRouter/sessionRouterService.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts new file mode 100644 index 00000000000000..2e1cc6c5ca41db --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { 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'; + +/** + * 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 { + // 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); + } + 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) { + // 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/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/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index d6bc7bae85211d..b6ea7c40d0cdd9 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/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..72a8f2f28fc187 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/sessionRouter.ts @@ -0,0 +1,210 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +/** + * 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`). + */ +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. + * + * The score is calibrated against the candidate's own metadata rather than the + * raw utterance length: it blends how much of the session's strongest identity + * field the utterance covers (recall, taken as the best match across label / + * repo / cwd so a strong label match is not diluted by repo or path tokens) with + * how much of the utterance those tokens consume (precision). This keeps an + * obvious label match routable even for long sentences instead of drowning it in + * unrelated utterance tokens. + */ +export function heuristicScore(request: ISessionRouteRequest): ISessionRouteResult[] { + const terms = new Set(tokenize(request.utterance)); + const results = request.sessions.map(session => { + if (!terms.size) { + return { sessionId: session.sessionId, confidence: 0 }; + } + 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 }; + } + 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; +} + +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..dd7c9611833bb8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.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 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); + }); + + 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}`); + }); +}); diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 3c43ccd0ab64da..7df36a503d66b4 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -233,6 +233,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';