diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 22d8baba885966..f8c2ba6c7f6780 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1116,6 +1116,9 @@ "--collapse-from-width", "--slide-from-x", "--slide-from-y", + "--omni-icon-column", + "--omni-rail", + "--omni-row-gap", "--vg-w1", "--vg-h1", "--vg-w2", 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..60848e04e13292 --- /dev/null +++ b/src/vs/editor/test/browser/controller/nativeEditContextUtils.test.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * 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)); + // 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, + focused, + trackerFocused: tracker.isFocused, + }, { + activeElement: true, + focused: true, + trackerFocused: true, + }); + }); +}); diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index 746a18d19b208e..699389e1cf4916 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/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 3f06fdc1eae5cb..002277503aac13 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -1881,29 +1881,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/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/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/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md index dcfdc18120e4b1..9806c2728e8df6 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 ef7b1fe2e92902..0eff24c492d64d 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 8bdd38909e1dbd..f0e52d8cc6d9ef 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'; @@ -43,6 +45,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'; @@ -63,8 +75,17 @@ export class AgentChatAccessibilityHelp implements IAccessibleViewImplementation } } -export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', 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.')); + 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.')); } @@ -80,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. Open its context menu{0} (for example Shift+F10), use the up and down arrow keys to choose Go on the Run, Come Back, Stable Colors, or Insiders Colors, and press Enter to activate the choice.', '')); @@ -170,7 +194,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 environmentService = accessor.get(IWorkbenchEnvironmentService); @@ -188,13 +212,20 @@ 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 : 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/actions/chatContextActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts index 5c0d324b88f826..945f4033f65786 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/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index b1eeacb7a21f60..13a37431545918 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -65,6 +65,8 @@ import { ChatWidgetHistoryService, IChatWidgetHistoryService } from '../common/w import { BYOKUtilityModelDefault, ChatAIDisabledSettingId, 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'; @@ -96,7 +98,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'; @@ -251,6 +253,12 @@ configurationRegistry.registerConfiguration({ tags: ['experimental'], agentsWindow: { default: true }, }, + 'chat.omni.enabled': { + type: 'boolean', + 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'] + }, 'chat.fontSize': { type: 'number', description: nls.localize('chat.fontSize', "Controls the font size in pixels in chat messages."), @@ -2826,6 +2834,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); @@ -2933,6 +2942,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 9dac42f2278011..61c642ecd28d52 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'; @@ -24,6 +25,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'; @@ -254,6 +256,7 @@ export interface IChatWidgetViewOptions { renderFollowups?: boolean; renderStyle?: 'compact' | 'minimal'; renderInputToolbarBelowInput?: boolean; + renderGettingStartedTip?: boolean; supportsFileReferences?: boolean; filter?: (item: ChatTreeItem) => boolean; /** @@ -307,8 +310,14 @@ 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; + 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/chatInputWindow.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts new file mode 100644 index 00000000000000..36fd87c418be69 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as dom from '../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.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_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'; + +// Registers the singleton implementation (side-effect import). +import './chatInputWindowService.js'; + +const inputWindowEnabled = ContextKeyExpr.and( + ChatContextKeys.enabled, + ContextKeyExpr.equals(`config.${OmniChatEnabledSettingId}`, true) +); + +CommandsRegistry.registerCommand(CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID, (accessor, text: string) => { + return accessor.get(IChatInputWindowService).acceptVoiceInput(text); +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + 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 { + const invokingWindow = dom.getActiveWindow(); + const invokingWindowBounds = { + x: invokingWindow.screenX, + y: invokingWindow.screenY, + width: invokingWindow.outerWidth, + height: invokingWindow.outerHeight, + }; + const chatInputWindowService = accessor.get(IChatInputWindowService); + await chatInputWindowService.toggleWindow(invokingWindowBounds); + } +}); + +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, + }); + } + 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..333b47c9568b16 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts @@ -0,0 +1,526 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * 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 { 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 { 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 { 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'; +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 { combineVoiceInput } from '../voiceClient/voiceInputUtils.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 + * 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; + private _widget: ChatWidget | 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. */ + private _openOperation: Promise | undefined; + private _actionWidgetRestoreHeight: number | undefined; + /** Immutable bounds of the window that invoked omni, captured before service resolution. */ + private _invokingWindowBounds: IRectangle = this._windowBounds(mainWindow); + + 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, + @ICommandService private readonly commandService: ICommandService, + ) { + 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(invokingWindowBounds?: IRectangle): Promise { + if (this._window) { + return; + } + // 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; + } catch (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; + } + } + + private async _doOpenWindow(): Promise { + const bounds = this._defaultBounds(); + + const auxiliaryWindow = await this.auxiliaryWindowService.open({ + bounds, + alwaysOnTop: true, + frameless: true, + transparent: true, + disableFullscreen: true, + nativeTitlebar: false, + noBackgroundThrottling: true, + backgroundColor: '#00000000', + }); + + 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.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(); + + 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'; + }; + + 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"), + })); + 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')); + + 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, 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', + '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)(() => { + 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(invokingWindowBounds?: IRectangle): Promise { + if (this.isOpen) { + this.closeWindow(); + } else { + this._ownershipChannel.postMessage({ type: 'claim' }); + await this.openWindow(invokingWindowBounds); + } + } + + 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 + // `monaco-workbench` class, so we only need the `.interactive-session` + // wrapper here. + const parent = dom.append(row, dom.$('.interactive-session')); + parent.style.flex = '1 1 auto'; + parent.style.minWidth = '0'; + + 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', + renderGettingStartedTip: false, + // Show only the input box — drop every response list item. + filter: () => false, + enableImplicitContext: false, + defaultMode: ChatMode.Ask, + 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. + submitHandler: (query, mode, attachedContext) => this._routingController?.handleSubmit(query, mode, attachedContext) ?? Promise.resolve(false), + onDidChangeModelPickerVisibility: visible => this._layoutForModelPicker(auxiliaryWindow, visible), + inputPickerPosition: AnchorPosition.BELOW, + }, + { + inputEditorBackground: inputBackground, + resultEditorBackground: editorBackground, + listBackground: editorBackground, + listForeground: editorBackground, + overlayBackground: editorBackground, + } + )); + this._widget = widget; + widget.render(parent); + widget.setVisible(true); + + const modelRef = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { disableBackgroundKeepAlive: true, debugOwner: 'ChatInputWindow' }); + this._modelRef = modelRef; + widget.setModel(modelRef.object); + + let fitWindowToInput = () => { }; + + // Route submissions through the shared controller, inserting its advisory + // 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, + onDidResolveRoute: (resource, kind) => { + this.commandService.executeCommand(CHAT_INPUT_WINDOW_SET_VOICE_TARGET_COMMAND_ID, resource?.toString(), kind).catch(() => { }); + }, + placeBadge: (badge) => { + const container = this._window?.container; + const row = this._row; + if (!container || !row) { + return; + } + 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(); + } + }); + observer.observe(container, { childList: true }); + this._windowDisposables.add(toDisposable(() => { + observer.disconnect(); + resizeObserver.disconnect(); + })); + }, + }; + this._routingController = this._windowDisposables.add(this.instantiationService.createInstance(ChatSessionRoutingController, host, 'chatInputWindow')); + + // 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 = () => { + if (this._actionWidgetRestoreHeight !== undefined) { + return; + } + const win = this._window?.window; + 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) + .reduce((height, child) => height + (child as HTMLElement).offsetHeight, 0); + const contentHeight = rowHeight + extraHeight + 2; + if (contentHeight === lastContentHeight) { + return; + } + lastContentHeight = contentHeight; + let x = win.screenX; + let y = win.screenY; + if (!didInitialPosition) { + didInitialPosition = true; + 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 }); + }; + + 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.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) { + 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 { + layingOut = false; + } + }; + 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 + // 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, () => { + 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 + // makes the caret actually render. + this._windowDisposables.add(dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => { + widget.focusInput(); + })); + })); + // 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)); + } + + 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; + 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; + await auxiliaryWindow.setBounds({ x: win.screenX, y: win.screenY, width: win.outerWidth, height: 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._widget = undefined; + this._row = undefined; + this._lead = undefined; + this._trail = undefined; + this._actionWidgetRestoreHeight = undefined; + this._modelRef?.dispose(); + this._modelRef = undefined; + } + + private _defaultBounds(): IRectangle { + 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(invokingWindowBounds.x + (invokingWindowBounds.width - width) / 2); + const y = Math.round(invokingWindowBounds.y + (invokingWindowBounds.height - CHAT_INPUT_WINDOW_DEFAULT_HEIGHT) / 2); + return { + x, + y, + width, + height: CHAT_INPUT_WINDOW_DEFAULT_HEIGHT, + }; + } + + private _defaultWidth(): number { + 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 { + return { + x: window.screenX, + y: window.screenY, + width: window.outerWidth, + height: window.outerHeight, + }; + } +} + +registerSingleton(IChatInputWindowService, ChatInputWindowService, InstantiationType.Delayed); 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..30ab52aa71ad97 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css @@ -0,0 +1,168 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + --omni-rail: 20px; + --omni-icon-column: 16px; + --omni-row-gap: 8px; + + border-radius: var(--vscode-cornerRadius-xLarge); + overflow: hidden; +} + +.chat-input-window:focus-within { + box-shadow: inset 0 0 0 1px var(--vscode-focusBorder); +} + +.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 .interactive-session { + height: auto; + justify-content: center; +} + +.chat-input-window .interactive-list { + display: none; +} + +.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; +} + +.chat-input-window .chat-input-container:not(.working) { + border-color: transparent !important; + background: transparent !important; +} + +.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, +.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 new file mode 100644 index 00000000000000..7ceaf24b02ae60 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts @@ -0,0 +1,833 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; +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 { 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 { IChatSessionHistoryItem, IChatSessionsService } from '../../common/chatSessionsService.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'; +import { IChatWidgetService } from '../chat.js'; +import { ChatWidget } from '../widget/chatWidget.js'; + +import './media/chatSessionRouting.css'; + +/** + * 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 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 + * 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'; + } +} + +/** 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 + * 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 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 when a single-target route resolves, or clear it for fan-out. */ + onDidResolveRoute?(resource: URI | undefined, kind?: 'existing_session' | 'new_session'): void; +} + +/** + * Shared routing + advisory-badge behaviour for chat input surfaces. Scores a + * 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 { + + /** 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, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @ISessionRouter private readonly sessionRouter: ISessionRouter, + @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._submitCts.value?.cancel(); + this._pendingSend.clear(); + + // 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; + } + + // 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, enriched); + this._beginPendingSend(target, results, enriched, 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 || !isHighConfidenceSessionRoute(top)) { + 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 + && isHighConfidenceSessionRoute(r) + && (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. 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 { + 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 + && session.providerType !== AgentSessionProviders.Local) + .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, + 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 }; + } + + /** + * 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' && 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 { + 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 select another destination, choose several, + * abort, or keep typing to cancel before it fires. + */ + 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); + badge.classList.add('chat-routing-badge-ranked'); + + const labelById = new Map(candidates.map(candidate => [candidate.sessionId, candidate.label])); + const ranked = results + .filter(result => isHighConfidenceSessionRoute(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 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 \u00B7 Escape to cancel"); + 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"); + }; + 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 = () => { + countdownEl.textContent = localize('chatSessionRouting.sendingIn', "sending in {0}s", remainingSeconds); + }; + + const send = () => { + this._pendingSend.clear(); + const sent = [...selection].sort((a, b) => a - b).map(index => options[index]); + 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, sent.length === 1) + ); + 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); + } + }); + }; + + 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(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(); + } + }, true)); + + // 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; + 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"), () => { + 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 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); + 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; + } + + 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' })); + 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, notifyRoute = true): Promise { + if (target.kind === 'new') { + return this._dispatchToNewSession(submittedInput, utterance, attachedContext, token, notifyRoute); + } + 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). */ + 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, 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, notifyRoute); + } + + 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, 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, notifyRoute); + } + // 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, notifyRoute); + } + } + + 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) { + ref.dispose(); + 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; + } + 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..8598f2aec643d5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/sessionRouter/media/chatSessionRouting.css @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * 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-top: 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); +} + +.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/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/speechToText/chatSpeechToTextService.ts b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts index a212c6b624f06e..47f26c14ee16ab 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts @@ -34,6 +34,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'; import { resolveDictationLanguage } from './dictationLanguage.js'; export const IChatSpeechToTextService = createDecorator('chatSpeechToTextService'); @@ -739,6 +740,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; @@ -783,7 +785,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'); @@ -795,7 +797,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'; @@ -806,7 +808,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 3104dd3d635bc6..fd5eaba41adfde 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 per 32 ms voice capture chunk at 16 kHz, matching one Silero VAD frame. */ export const MIC_CAPTURE_CHUNK_SIZE = 512; @@ -242,7 +247,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 { @@ -372,12 +377,13 @@ 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; } if (this._capturePromise) { return this._capturePromise; } - const capturePromise = this._startCapture(window); + const capturePromise = this._startCapture(captureWindow); this._capturePromise = capturePromise; try { await capturePromise; diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index daca646139b140..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, @@ -251,7 +252,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 +333,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; @@ -772,6 +774,12 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC ) { super(); + 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); + } + })); + // 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. @@ -2078,6 +2086,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 +2226,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 +2749,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 +2758,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 */ }); @@ -3151,9 +3163,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_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID, 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); @@ -5798,6 +5816,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 +5845,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/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 916386cc7fa870..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; } @@ -2089,6 +2093,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) { @@ -2752,7 +2758,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/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index ff9be36dd0d785..e128fc5e8d69a5 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'; @@ -252,6 +253,8 @@ export interface IChatInputPartOptions { */ inputPartHorizontalPadding?: number; onDidChangeInputOnboardingVisible?: (visible: boolean) => void; + onDidChangeModelPickerVisibility?: (visible: boolean) => void | Promise; + inputPickerPosition?: AnchorPosition; } export interface IWorkingSetEntry { @@ -1171,6 +1174,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, }; } @@ -3161,6 +3166,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..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 @@ -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,9 @@ 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 readonly _activeShowDisposables = this._register(new MutableDisposable()); + private _showRequestId = 0; private _domNode: HTMLElement | undefined; private _badgeIcon: HTMLElement | undefined; @@ -411,6 +415,10 @@ export class ModelPickerWidget extends Disposable { return; } 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); return; } @@ -490,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); @@ -527,6 +538,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 +548,14 @@ export class ModelPickerWidget extends Disposable { action.run(); }, onHide: () => { - hoverDisposables.dispose(); + this._showRequestId++; + 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)) { previouslyFocusedElement.focus(); } @@ -545,18 +563,50 @@ export class ModelPickerWidget extends Disposable { }; this._nameButton?.setAttribute('aria-expanded', 'true'); + 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( + '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, () => { + if (this._activeShowDisposables.value === showDisposables) { + this._activeShowDisposables.clear(); + } + }); + } else { + showActionWidget(); + } + } - this._actionWidgetService.show( - 'ChatModelPicker', - false, - items, - delegate, - anchorElement, - undefined, - [], - getModelPickerAccessibilityProvider(), - listOptions - ); + override dispose(): void { + this._showRequestId++; + this._activeShowDisposables.clear(); + if (this._nameButton?.getAttribute('aria-expanded') === 'true') { + this._actionWidgetService.hide(true); + } + super.dispose(); } private _updateBadge(): void { diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 466353d4913ac7..c6069cd03eae60 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..7bd6c3bd5cb287 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.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. + */ +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; + + /** 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. + */ + openWindow(invokingWindowBounds?: IRectangle): Promise; + + /** + * Closes the floating chat input window. No-op if already closed. + */ + closeWindow(): void; + + /** + * Toggles the floating chat input window open/closed. + */ + toggleWindow(invokingWindowBounds?: IRectangle): 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..2a0b2597687f8b --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/sessionRouter.ts @@ -0,0 +1,244 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +/** 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`). + */ +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; + /** 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. */ +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; +} + +/** + * 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 + * 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}`); } + 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}]', + '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 both as the cheap stage-1 pre-rank and as + * the fallback when no scoring model is available. Token-overlap heuristic over + * the session's identity/content fields (label, repo, cwd, description, and, + * when enriched, its first/most-recent request and most-recent response). + * + * 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 the fields + * 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, session.description, session.firstRequest, session.lastRequest, session.lastResponse].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/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/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index ff82ba4e572937..69a9e62ce9f9ea 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 @@ -74,6 +74,22 @@ suite('Chat Accessibility Help', () => { }); }); + 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, + }); + }); + test('only describes spoken agent progress in agent mode', () => { const keybindingService = { lookupKeybindings: () => [], 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..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'; @@ -428,13 +429,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_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID && typeof args[0] === 'string') { + if (this.omniFocused) { + this.acceptedOmniInputs.push(args[0]); + } + result = this.omniFocused; } return result as T; } @@ -3581,6 +3592,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. @@ -4207,6 +4235,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(); @@ -4513,7 +4569,6 @@ suite('VoiceSessionController live transcription', () => { onDidChangeFocusedSession: Event.None, getAllWidgets: () => [], }); - const controller = store.add(instantiationService.createInstance(VoiceSessionController)); controller['_isConnected'].set(true, undefined); controller['_userLogin'] = 'test-user'; 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..ed2b6792c901d1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * 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, isHighConfidenceSessionRoute, ISessionRouteRequest, parseRouterResponse, ROUTER_FIELD_CLIP_LENGTH } 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('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'])); + 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('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'); + assert.ok(ranked[0].confidence > ranked[1].confidence); + }); + + test('heuristicScore matches on enriched content, not just the label', () => { + const ranked = heuristicScore({ + utterance: 'update the authentication token refresh logic', + sessions: [ + { 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 > 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('...')); + }); +}); 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); 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';