From 2fb3e37e0f7d952f61187ec8cf21d0e058c885a5 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Fri, 25 Sep 2026 00:18:50 -0700 Subject: [PATCH] Show active background shells in agent chats --- .../actionWidget/browser/actionList.ts | 49 ++++-- .../test/browser/actionList.test.ts | 84 +++++++++ src/vs/platform/agentHost/common/agent.ts | 2 + .../state/protocol/action-origin.generated.ts | 8 +- .../state/protocol/channels-chat/actions.ts | 30 +++- .../state/protocol/channels-chat/reducer.ts | 23 +++ .../state/protocol/channels-chat/state.ts | 47 +++++ .../common/state/protocol/common/actions.ts | 6 +- .../common/state/protocol/version/registry.ts | 2 + .../agentHost/common/state/sessionState.ts | 2 + .../agentHost/node/agentHostStateManager.ts | 10 +- .../platform/agentHost/node/agentService.ts | 37 ++-- .../agentHost/node/copilot/copilotAgent.ts | 21 ++- .../node/copilot/copilotAgentSession.ts | 95 ++++++++-- .../test/node/agentHostStateManager.test.ts | 40 +++++ .../agentHost/test/node/agentService.test.ts | 26 +++ .../test/node/copilotAgentSession.test.ts | 166 ++++++++++++++++++ .../browser/sessionBackgroundShellsControl.ts | 118 +++++++++++++ .../chat/browser/sessionChatInputToolbar.ts | 3 + .../browser/sessionsChatAccessibilityHelp.ts | 1 + .../sessionBackgroundShellsControl.test.ts | 112 ++++++++++++ .../browser/baseAgentHostSessionsProvider.ts | 9 +- .../localAgentHostSessionsProvider.test.ts | 32 ++++ .../services/sessions/common/session.ts | 12 ++ src/vs/workbench/browser/chatDropdownPill.ts | 3 + .../contrib/chat/browser/chatInputPills.ts | 4 +- .../chat/browser/sessionChatPillOptions.ts | 15 ++ .../contrib/chat/common/sessionChatPills.ts | 3 + .../chat/test/browser/chatInputPills.test.ts | 22 ++- .../chat/test/common/sessionChatPills.test.ts | 3 + .../workbench/test/browser/chatPills.test.ts | 41 ++++- 31 files changed, 969 insertions(+), 57 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/browser/sessionBackgroundShellsControl.ts create mode 100644 src/vs/sessions/contrib/chat/test/browser/sessionBackgroundShellsControl.test.ts diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 2615570a1b2be9..d013a223ad12a6 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -97,6 +97,8 @@ export interface IActionListItemHover { readonly panelClassName?: string; /** Align the panel's adjoining edge with the outer action widget rather than its inset list. */ readonly alignToParent?: boolean; + /** Align the panel's bottom with the parent popup, keeping content above its trigger. */ + readonly alignToParentBottom?: boolean; /** Keep the initial top edge as content or rows change, scrolling at the viewport edge. */ readonly preserveVerticalPosition?: boolean; /** Align the panel's top edge with the row that opened it. */ @@ -165,7 +167,7 @@ export interface IActionListItem { * the chevron opens an inline submenu with these actions. */ readonly submenuActions?: IAction[]; - /** When true, clicking the row opens its submenu instead of selecting the item. */ + /** Open a submenu or expandable hover on row activation instead of selecting the item. */ readonly openSubmenuOnClick?: boolean; /** Options for the action list rendered in the nested submenu panel. */ readonly submenuOptions?: IActionListOptions; @@ -2303,11 +2305,15 @@ export class ActionListWidget extends Disposable { return; } } - if (element.openSubmenuOnClick && element.submenuActions?.length && (isPointerActivation || e.browserEvent instanceof AcceptSelectedEvent)) { + if (element.openSubmenuOnClick && (element.submenuActions?.length || element.hover?.expandable) && (isPointerActivation || e.browserEvent instanceof AcceptSelectedEvent)) { this._list.setSelection([]); this._showSubmenuForItem(element); if (!isPointerActivation) { - this._currentSubmenuWidget?.focus(); + if (this._currentSubmenuWidget) { + this._currentSubmenuWidget.focus(); + } else { + this._submenuContainer.focus(); + } } return; } @@ -2570,7 +2576,7 @@ export class ActionListWidget extends Disposable { const preserveVerticalPosition = element.hover?.preserveVerticalPosition; const hasSubmenuActions = !!element.submenuActions?.length; - const scrollableContent = preserveVerticalPosition || (!hasSubmenuActions && !this._options?.persistentHover && !element.hover?.alignToParent); + const scrollableContent = preserveVerticalPosition || element.hover?.alignToParentBottom || (!hasSubmenuActions && !this._options?.persistentHover && !element.hover?.alignToParent); const content = scrollableContent ? dom.$('.action-list-submenu-content') : this._submenuContainer; const viewport = scrollableContent ? dom.$('.action-list-submenu-viewport', undefined, content) : undefined; const scrollbar = viewport && !hasSubmenuActions ? this._submenuDisposables.add(new DomScrollableElement(viewport, { @@ -2777,7 +2783,7 @@ export class ActionListWidget extends Disposable { } row?.setAttribute('aria-expanded', 'true'); const parentRect = this.domNode.getBoundingClientRect(); - const alignToParent = persistent || currentElement.hover?.alignToParent; + const alignToParent = persistent || currentElement.hover?.alignToParent || currentElement.hover?.alignToParentBottom; const edgeRect = alignToParent ? this.domNode.parentElement?.closest('.action-widget')?.getBoundingClientRect() ?? parentRect : parentRect; @@ -2845,13 +2851,16 @@ export class ActionListWidget extends Disposable { openingPanelHeight ??= panelHeight / zoom; } const anchorHeight = openingPanelHeight !== undefined ? openingPanelHeight * zoom : panelHeight; - let top = openingPanelTop !== undefined - ? openingPanelTop * zoom - : row && currentElement.hover?.alignToAnchorTop - ? anchorRect.top - parentRect.top - : row - ? anchorRect.top - parentRect.top + (anchorRect.height - anchorHeight) / 2 - : panelRect.top - parentRect.top; + const bottomBoundary = currentElement.hover?.alignToParentBottom ? Math.min(edgeRect.bottom, targetWindow.innerHeight) : targetWindow.innerHeight; + let top = currentElement.hover?.alignToParentBottom + ? bottomBoundary - parentRect.top - anchorHeight + : openingPanelTop !== undefined + ? openingPanelTop * zoom + : row && currentElement.hover?.alignToAnchorTop + ? anchorRect.top - parentRect.top + : row + ? anchorRect.top - parentRect.top + (anchorRect.height - anchorHeight) / 2 + : panelRect.top - parentRect.top; if (preserveVerticalPosition && currentElement.hover?.alignToAnchorTop && viewport && submenuWidget) { const outerChromeHeight = panelRect.height - viewport.getBoundingClientRect().height; const submenuChromeHeight = (submenuWidget.headerContainer?.offsetHeight ?? 0) @@ -2861,8 +2870,8 @@ export class ActionListWidget extends Disposable { top = Math.min(top, targetWindow.innerHeight - parentRect.top - desiredPanelHeight - 8); } const panelBottom = parentRect.top + top + anchorHeight; - if (panelBottom > targetWindow.innerHeight && !(preserveVerticalPosition && currentElement.hover?.alignToAnchorTop)) { - top -= panelBottom - targetWindow.innerHeight + 8; + if (panelBottom > bottomBoundary && !(preserveVerticalPosition && currentElement.hover?.alignToAnchorTop)) { + top -= panelBottom - bottomBoundary + 8; } if (parentRect.top + top < 0) { top = -parentRect.top; @@ -2871,14 +2880,17 @@ export class ActionListWidget extends Disposable { if (preserveVerticalPosition) { openingPanelTop ??= top / zoom; } + const availablePanelHeight = currentElement.hover?.alignToParentBottom + ? bottomBoundary - 8 + : targetWindow.innerHeight - parentRect.top - top - 8; if (viewport && scrollbar) { const chromeHeight = (panelRect.height - scrollbar.getDomNode().getBoundingClientRect().height) / zoom; - const availableHeight = Math.max(0, (targetWindow.innerHeight - parentRect.top - top - 8) / zoom - chromeHeight); + const availableHeight = Math.max(0, availablePanelHeight / zoom - chromeHeight); viewport.style.height = `${Math.min(content.getBoundingClientRect().height / zoom, availableHeight)}px`; scrollbar.scanDomNode(); } else if (viewport && submenuWidget) { const chromeHeight = (panelRect.height - viewport.getBoundingClientRect().height) / zoom; - const availableHeight = Math.max(0, (targetWindow.innerHeight - parentRect.top - top - 8) / zoom - chromeHeight); + const availableHeight = Math.max(0, availablePanelHeight / zoom - chromeHeight); const submenuChromeHeight = (submenuWidget.headerContainer?.offsetHeight ?? 0) + (submenuWidget.filterContainer?.offsetHeight ?? 0) + (submenuWidget.footerContainer?.offsetHeight ?? 0); @@ -2886,6 +2898,9 @@ export class ActionListWidget extends Disposable { submenuWidget.layout(submenuHeight, maxWidth); viewport.style.height = `${submenuChromeHeight + submenuHeight}px`; } + if (currentElement.hover?.alignToParentBottom) { + top = Math.max(0, bottomBoundary - this._submenuContainer.getBoundingClientRect().height) - parentRect.top; + } this._submenuContainer.style.top = `${top / zoom}px`; }; this._layoutSubmenu = layout; @@ -2893,7 +2908,7 @@ export class ActionListWidget extends Disposable { // tabThroughPanel content (e.g. a GitHub reference hover) can grow when // focus reveals bounded text, in which case the panel must reposition // itself, not just the row that measured it before the content changed. - if ((this._options?.persistentHover || element.hover?.alignToParent || element.hover?.tabThroughPanel || preserveVerticalPosition) && this._currentSubmenuElement === element) { + if ((this._options?.persistentHover || element.hover?.alignToParent || element.hover?.tabThroughPanel || preserveVerticalPosition || element.hover?.alignToParentBottom) && this._currentSubmenuElement === element) { if (!submenuWidget) { const scheduledLayout = this._submenuDisposables.add(new MutableDisposable()); const observer = this._submenuDisposables.add(new dom.DisposableResizeObserver('ActionListWidget.hoverPanel', () => { diff --git a/src/vs/platform/actionWidget/test/browser/actionList.test.ts b/src/vs/platform/actionWidget/test/browser/actionList.test.ts index e5933bd55ba0b2..19d803af63a0d6 100644 --- a/src/vs/platform/actionWidget/test/browser/actionList.test.ts +++ b/src/vs/platform/actionWidget/test/browser/actionList.test.ts @@ -514,6 +514,90 @@ suite('ActionListWidget', () => { }); } + for (const activation of ['click', 'tap', 'keyboard'] as const) { + test(`${activation} opens opted-in live details and preserves them through refresh`, () => { + const selected: string[] = []; + const content = document.createElement('div'); + content.textContent = 'Running, Attached, 1s'; + const item = (): IActionListItem => ({ + ...action('shell'), + hover: { content, expandable: true }, + openSubmenuOnClick: true, + }); + const widget = createActionListWidget(disposables, { + items: [item()], + onSelect: entry => selected.push(entry.id), + listOptions: { showFilter: false }, + }); + widget.focus(); + const row = widget.domNode.querySelector('.monaco-list-row')!; + if (activation === 'keyboard') { + widget.acceptSelected(); + } else if (activation === 'tap') { + row.dispatchEvent(Object.assign(new CustomEvent(TouchEventType.Tap, { bubbles: true }), { initialTarget: row })); + } else { + row.click(); + } + const panel = widget.domNode.querySelector('.action-list-submenu-panel')!; + const opened = panel.style.display !== 'none' && panel.contains(content); + const keyboardFocused = activation !== 'keyboard' || document.activeElement === panel; + content.textContent = 'Running, Attached, 2s'; + widget.updateItems([item()], undefined, { preserveHover: true }); + const retained = panel.style.display !== 'none' && panel.contains(content); + const elapsed = panel.textContent?.includes('Running, Attached, 2s'); + widget.updateItems([], undefined, { preserveHover: true }); + + assert.deepStrictEqual({ selected, opened, keyboardFocused, retained, elapsed, closedOnCompletion: panel.style.display === 'none' }, { + selected: [], opened: true, keyboardFocused: true, retained: true, elapsed: true, closedOnCompletion: true, + }); + }); + } + + for (const zoom of [1, 1.25]) { + for (const contentHeight of [80, 800]) { + test(`bottom-aligned details remain above the input boundary at ${zoom} zoom with ${contentHeight}px content`, async () => { + const content = document.createElement('div'); + content.style.cssText = `width: 200px; height: ${contentHeight}px;`; + content.textContent = 'Background shell details'; + const item = (): IActionListItem => ({ + ...action('shell'), + hover: { content, expandable: true, alignToParentBottom: true }, + openSubmenuOnClick: true, + }); + const widget = createActionListWidget(disposables, { + items: [item()], + listOptions: { showFilter: false }, + }); + const popup = document.createElement('div'); + popup.className = 'action-widget'; + popup.style.cssText = `position: fixed; top: 160px; left: 40px; zoom: ${zoom};`; + document.body.appendChild(popup); + disposables.add({ dispose: () => popup.remove() }); + popup.appendChild(widget.domNode); + widget.layout(24, 240); + widget.focus(); + widget.acceptSelected(); + await settleLayout(); + const panel = widget.domNode.querySelector('.action-list-submenu-panel')!; + const initial = panel.getBoundingClientRect(); + for (let i = 0; i < 3; i++) { + widget.updateItems([item()], undefined, { preserveHover: true }); + await settleLayout(); + } + const updated = panel.getBoundingClientRect(); + const bottom = popup.getBoundingClientRect().bottom; + + assert.deepStrictEqual({ + initialAbove: initial.bottom <= bottom + 1, + updatedAbove: updated.bottom <= bottom + 1, + withinViewport: updated.top >= -1, + stableHeight: Math.abs(updated.height - initial.height) < 1, + visible: updated.height > 0, + }, { initialAbove: true, updatedAbove: true, withinViewport: true, stableHeight: true, visible: true }); + }); + } + } + test('keyboard activation on an opted-in submenu row focuses its filter without selecting it', () => { const selected: string[] = []; const widget = createActionListWidget(disposables, { diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 951d33c55b645d..54175bbb2f8f02 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -1345,6 +1345,8 @@ export interface IAgent { readonly onDidChangeChatHistory?: Event; /** Observe another client's persisted transcript while a host client subscribes to this chat. */ watchChatHistory?(chat: URI): IDisposable; + /** Refresh background shell inventory while a client observes an already-hydrated chat. */ + watchChatBackgroundShells?(chat: URI): IDisposable; /** Starts provider-owned native chat discovery; repeated calls are idempotent. */ startChatDiscovery?(): Promise; diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index 9fc21dd8f66642..05d3901dea42b1 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -9,7 +9,7 @@ // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. -import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionWorkingDirectoryReplacedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionMcpServerBackgroundRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatTurnResumeAction, type ChatActivityChangedAction, type ChatChangesetsChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatIsArchivedChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction, type AutomationCreateRequestedAction, type AutomationUpdateRequestedAction, type AutomationSetAction, type AutomationRemovedAction, type AutomationRunLifecycleChangedAction, type AutomationRunSessionSetAction, type AutomationRunSessionRemovedAction, type AutomationRunPrimarySessionChangedAction, type AutomationRunCancelRequestedAction } from './actions.js'; +import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionWorkingDirectoryReplacedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionMcpServerBackgroundRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatTurnResumeAction, type ChatActivityChangedAction, type ChatBackgroundShellSetAction, type ChatBackgroundShellRemovedAction, type ChatChangesetsChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatIsArchivedChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction, type AutomationCreateRequestedAction, type AutomationUpdateRequestedAction, type AutomationSetAction, type AutomationRemovedAction, type AutomationRunLifecycleChangedAction, type AutomationRunSessionSetAction, type AutomationRunSessionRemovedAction, type AutomationRunPrimarySessionChangedAction, type AutomationRunCancelRequestedAction } from './actions.js'; // ─── Root vs Session vs Chat vs Terminal vs Changeset Action Unions ───────────────── @@ -123,6 +123,8 @@ export type ChatAction = | ChatErrorAction | ChatTurnResumeAction | ChatActivityChangedAction + | ChatBackgroundShellSetAction + | ChatBackgroundShellRemovedAction | ChatChangesetsChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction @@ -173,6 +175,8 @@ export type ServerChatAction = | ChatTurnCompleteAction | ChatErrorAction | ChatActivityChangedAction + | ChatBackgroundShellSetAction + | ChatBackgroundShellRemovedAction | ChatChangesetsChangedAction | ChatUsageAction | ChatReasoningAction @@ -379,6 +383,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatError]: false, [ActionType.ChatTurnResume]: true, [ActionType.ChatActivityChanged]: false, + [ActionType.ChatBackgroundShellSet]: false, + [ActionType.ChatBackgroundShellRemoved]: false, [ActionType.ChatChangesetsChanged]: false, [ActionType.ChatWorkingDirectorySet]: true, [ActionType.ChatWorkingDirectoryRemoved]: true, diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts index 419f3b46273c78..9a5c57d0bffb02 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts @@ -10,7 +10,7 @@ import { ActionType } from '../common/actions.js'; import type { StringOrMarkdown, FileEdit, UsageInfo, URI } from '../common/state.js'; import type { Changeset } from '../channels-changeset/state.js'; import type { McpAuthRequirement } from '../channels-session/state.js'; -import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type Message, type ResponsePart, type ToolCallResult, type ToolResultContent, type ChatInputAnswer, type ChatInputRequest, type ChatInputResponseKind, type ConfirmationOption, type ErrorResponsePart, type ToolCallContributor, type ToolCallRiskAssessment, type ToolInput, type Turn } from './state.js'; +import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type BackgroundShellInfo, type Message, type ResponsePart, type ToolCallResult, type ToolResultContent, type ChatInputAnswer, type ChatInputRequest, type ChatInputResponseKind, type ConfirmationOption, type ErrorResponsePart, type ToolCallContributor, type ToolCallRiskAssessment, type ToolInput, type Turn } from './state.js'; // ─── Tool Call Action Base ─────────────────────────────────────────────────── @@ -529,6 +529,32 @@ export interface ChatActivityChangedAction { activity?: string; } +/** + * Adds or replaces an active background shell by ID, independently of turn state. + * Hosts mirror the resulting inventory through `session/chatUpdated`. + * + * @category Chat Actions + * @version 1 + */ +export interface ChatBackgroundShellSetAction { + type: ActionType.ChatBackgroundShellSet; + /** Complete shell metadata. */ + shell: BackgroundShellInfo; +} + +/** + * Removes a finished or no-longer-tracked background shell; unknown IDs are a no-op. + * Hosts mirror the resulting inventory through `session/chatUpdated`. + * + * @category Chat Actions + * @version 1 + */ +export interface ChatBackgroundShellRemovedAction { + type: ActionType.ChatBackgroundShellRemoved; + /** Identifier scoped to the owning chat. */ + shellId: string; +} + /** * The {@link Changeset | catalogue of changesets} the agent host advertises * for this chat changed. Replaces @@ -869,6 +895,8 @@ export type ChatAction = | ChatErrorAction | ChatTurnResumeAction | ChatActivityChangedAction + | ChatBackgroundShellSetAction + | ChatBackgroundShellRemovedAction | ChatChangesetsChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts index aa666cc2cf2c22..9133336001439c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts @@ -430,6 +430,29 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st case ActionType.ChatActivityChanged: return { ...state, activity: action.activity }; + case ActionType.ChatBackgroundShellSet: { + const list = state.backgroundShells ?? []; + const idx = list.findIndex(shell => shell.id === action.shell.id); + const next = list.slice(); + if (idx < 0) { + next.push(action.shell); + } else { + next[idx] = action.shell; + } + return { ...state, backgroundShells: next }; + } + + case ActionType.ChatBackgroundShellRemoved: { + const list = state.backgroundShells ?? []; + const idx = list.findIndex(shell => shell.id === action.shellId); + if (idx < 0) { + return state; + } + const next = list.slice(); + next.splice(idx, 1); + return { ...state, backgroundShells: next }; + } + case ActionType.ChatChangesetsChanged: { const { changesets: _omit, ...stateWithoutChangesets } = state; return action.changesets diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts index c90faddf8af244..ef7dbcfcef8e25 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts @@ -38,6 +38,8 @@ export interface ChatState { status: SessionStatus; /** Human-readable description of what the chat is currently doing */ activity?: string; + /** Active background shells owned by this chat, independent of its current turn. */ + backgroundShells?: BackgroundShellInfo[]; /** Last modification timestamp (ISO 8601, e.g. `"2025-03-10T18:42:03.123Z"`) */ modifiedAt: string; /** How this chat came into existence */ @@ -130,6 +132,8 @@ export interface ChatSummary { status: SessionStatus; /** Human-readable description of what the chat is currently doing */ activity?: string; + /** Active background shells, mirrored from {@link ChatState.backgroundShells}. */ + backgroundShells?: BackgroundShellInfo[]; /** Last modification timestamp (ISO 8601, e.g. `"2025-03-10T18:42:03.123Z"`) */ modifiedAt: string; /** How this chat came into existence */ @@ -149,6 +153,49 @@ export interface ChatSummary { workingDirectories?: URI[]; } +/** + * Activity of a background shell that has not finished. + * + * @category Background Shells + * @nonexhaustive + */ +export const enum BackgroundShellStatus { + Running = 'running', + Idle = 'idle', +} + +/** + * Whether a background shell is retained by its agent or runs independently. + * + * @category Background Shells + * @nonexhaustive + */ +export const enum BackgroundShellAttachmentMode { + Attached = 'attached', + Detached = 'detached', +} + +/** + * Metadata for a shell command continuing outside its initiating tool call. + * Shell identity is scoped to the owning chat, not to a turn or terminal. + * + * @category Background Shells + */ +export interface BackgroundShellInfo { + /** Stable identifier within the owning chat. */ + id: string; + /** Human-readable description of the command's purpose. */ + description: string; + /** Command line, displayed as plain text. */ + command: string; + /** Current activity of the unfinished shell. */ + status: BackgroundShellStatus; + /** ISO 8601 timestamp when the command started. */ + startedAt: string; + /** Whether the shell remains attached to the agent's lifetime. */ + attachmentMode: BackgroundShellAttachmentMode; +} + /** * Discriminant for {@link ChatOrigin} — how a chat came into existence. * diff --git a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts index 123987069ffbea..740018a4e1b3fa 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts @@ -12,7 +12,7 @@ import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerm import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionWorkingDirectorySetAction, SessionWorkingDirectoryRemovedAction, SessionWorkingDirectoryReplacedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionMcpServerBackgroundRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; -import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatTurnResumeAction, ChatActivityChangedAction, ChatChangesetsChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatIsArchivedChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; +import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatTurnResumeAction, ChatActivityChangedAction, ChatBackgroundShellSetAction, ChatBackgroundShellRemovedAction, ChatChangesetsChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatIsArchivedChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, ChangesetFilesReviewChangedAction, ChangesetContentChangedAction, ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, ChangesetClearedAction } from '../channels-changeset/actions.js'; @@ -58,6 +58,8 @@ export const enum ActionType { ChatError = 'chat/error', ChatTurnResume = 'chat/turnResume', ChatActivityChanged = 'chat/activityChanged', + ChatBackgroundShellSet = 'chat/backgroundShellSet', + ChatBackgroundShellRemoved = 'chat/backgroundShellRemoved', ChatChangesetsChanged = 'chat/changesetsChanged', ChatWorkingDirectorySet = 'chat/workingDirectorySet', ChatWorkingDirectoryRemoved = 'chat/workingDirectoryRemoved', @@ -218,6 +220,8 @@ export type StateAction = | ChatErrorAction | ChatTurnResumeAction | ChatActivityChangedAction + | ChatBackgroundShellSetAction + | ChatBackgroundShellRemovedAction | ChatChangesetsChangedAction | ChatWorkingDirectorySetAction | ChatWorkingDirectoryRemovedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index f5ab514e83cffb..cadd17ce1d0e4c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -129,6 +129,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatError]: '0.4.0', [ActionType.ChatTurnResume]: '0.9.0', [ActionType.ChatActivityChanged]: '0.5.0', + [ActionType.ChatBackgroundShellSet]: '0.9.0', + [ActionType.ChatBackgroundShellRemoved]: '0.9.0', [ActionType.ChatChangesetsChanged]: '0.9.0', [ActionType.ChatWorkingDirectorySet]: '0.7.0', [ActionType.ChatWorkingDirectoryRemoved]: '0.7.0', diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 5ac0a7a37c3b5f..4d9e55292e5af4 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -976,6 +976,7 @@ export function createChatState(summary: ChatSummary): ChatState { origin: summary.origin, interactivity: summary.interactivity, workingDirectories: summary.workingDirectories, + ...(summary.backgroundShells !== undefined ? { backgroundShells: summary.backgroundShells } : {}), turns: [], activeTurn: undefined, }; @@ -1076,6 +1077,7 @@ export function chatSummaryFromState(state: ChatState): ChatSummary { if (state.origin !== undefined) { summary.origin = state.origin; } if (state.interactivity !== undefined) { summary.interactivity = state.interactivity; } if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; } + if (state.backgroundShells !== undefined) { summary.backgroundShells = state.backgroundShells; } return summary; } diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 9578612e8e28e8..ee44086383a3a9 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -2124,11 +2124,19 @@ export class AgentHostStateManager extends Disposable { // SessionState subscribers (the per-chat tabs) reflect this chat's // progress — not just the aggregated session summary. Status changes // at most a couple of times per turn, so this won't flood the channel. + const summaryChanges: Partial = {}; if (prevEntry?.status !== nextEntry.status) { + summaryChanges.status = nextEntry.status; + summaryChanges.activity = nextEntry.activity; + } + if (prevEntry?.backgroundShells !== nextEntry.backgroundShells) { + summaryChanges.backgroundShells = nextEntry.backgroundShells; + } + if (Object.keys(summaryChanges).length > 0) { this.dispatchServerAction(sessionKey, { type: ActionType.SessionChatUpdated, chat: chatUri, - changes: { status: nextEntry.status, activity: nextEntry.activity }, + changes: summaryChanges, }); } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 483bfa53a469b2..d72b1718e5cf67 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -675,7 +675,7 @@ export class AgentService extends Disposable implements IAgentService { private readonly _disposingPeerChats = new Set(); private readonly _defaultChatBackingWrites = new Map>(); private readonly _chatHistoryRefreshes = this._register(new ThrottlerByKey()); - private readonly _chatHistoryWatches = this._register(new DisposableResourceMap()); + private readonly _chatWatches = this._register(new DisposableResourceMap()); private readonly _pendingChatHistories = new Map(); private readonly _authService: AgentHostAuthenticationService; /** Shared side-effect handler for action dispatch and session lifecycle. */ @@ -939,9 +939,9 @@ export class AgentService extends Disposable implements IAgentService { } })); this._register(this._stateManager.onDidRemoveSession(session => { - for (const chat of this._chatHistoryWatches.keys()) { + for (const chat of this._chatWatches.keys()) { if (parseRequiredSessionUriFromChatUri(chat) === session) { - this._chatHistoryWatches.deleteAndDispose(chat); + this._chatWatches.deleteAndDispose(chat); this._pendingChatHistories.delete(chat.toString()); } } @@ -4960,7 +4960,7 @@ export class AgentService extends Disposable implements IAgentService { } async disposeChat(session: URI, chat: URI): Promise { - this._chatHistoryWatches.deleteAndDispose(chat); + this._chatWatches.deleteAndDispose(chat); this._pendingChatHistories.delete(chat.toString()); const sessionKey = session.toString(); const chatKey = chat.toString(); @@ -5239,7 +5239,7 @@ export class AgentService extends Disposable implements IAgentService { const session = URI.parse(parseRequiredSessionUriFromChatUri(chat)); await this._restoreSessionInFlight.get(session.toString()); const previous = this._stateManager.getChatState(chat.toString()); - const watch = this._chatHistoryWatches.get(chat); + const watch = this._chatWatches.get(chat); if (!previous || this._store.isDisposed || this._providerService.getProviderForSession(session) !== provider || !this._subscriptions.hasSubscribers(chat)) { return; } @@ -5253,7 +5253,7 @@ export class AgentService extends Disposable implements IAgentService { const refreshed = previous.turns.map(turn => byId.get(turn.id) ?? turn); const existing = new Set(previous.turns.map(turn => turn.id)); refreshed.push(...turns.filter(turn => !existing.has(turn.id))); - if (!this._store.isDisposed && this._providerService.getProviderForSession(session) === provider && this._subscriptions.hasSubscribers(chat) && this._chatHistoryWatches.get(chat) === watch) { + if (!this._store.isDisposed && this._providerService.getProviderForSession(session) === provider && this._subscriptions.hasSubscribers(chat) && this._chatWatches.get(chat) === watch) { const current = this._stateManager.getChatState(chat.toString()); if (current?.activeTurn) { this._pendingChatHistories.set(chat.toString(), { provider, chat, turns: providerTurns }); @@ -6255,7 +6255,7 @@ export class AgentService extends Disposable implements IAgentService { } this._sessionResidency.touch(resource); void this._sessionResidency.reconcile(); - this._watchChatHistory(resource); + this._watchChatUpdates(resource); if (isAhpChatChannel(resourceStr)) { await this._chatInputService.prepareChat(resource); if (this._store.isDisposed || (isActive && !isActive())) { @@ -6325,7 +6325,7 @@ export class AgentService extends Disposable implements IAgentService { // it cares about (e.g. uncommitted changeset → trigger refresh). if (this._subscriptions.addSubscriber(resource, clientId)) { this._changesetCoordinator.onFirstSubscriber(resource); - this._watchChatHistory(resource); + this._watchChatUpdates(resource); } this._sessionResidency.touch(resource); } @@ -6337,7 +6337,7 @@ export class AgentService extends Disposable implements IAgentService { if (!this._subscriptions.removeSubscriber(resource, clientId)) { return; } - this._chatHistoryWatches.deleteAndDispose(resource); + this._chatWatches.deleteAndDispose(resource); this._pendingChatHistories.delete(resource.toString()); if (isAhpChatChannel(resource.toString())) { this._chatInputService.clear(parseRequiredSessionUriFromChatUri(resource.toString()), resource.toString()); @@ -6352,14 +6352,23 @@ export class AgentService extends Disposable implements IAgentService { void this._sessionResidency.reconcile(); } - private _watchChatHistory(chat: URI): void { - if (!isAhpChatChannel(chat.toString()) || !this._subscriptions.hasSubscribers(chat) || !this._stateManager.getChatState(chat.toString()) || this._chatHistoryWatches.has(chat)) { + private _watchChatUpdates(chat: URI): void { + if (!isAhpChatChannel(chat.toString()) || !this._subscriptions.hasSubscribers(chat) || !this._stateManager.getChatState(chat.toString()) || this._chatWatches.has(chat)) { return; } const session = URI.parse(parseRequiredSessionUriFromChatUri(chat)); - const watch = this._providerService.getProviderForSession(session)?.watchChatHistory?.(chat); - if (watch) { - this._chatHistoryWatches.set(chat, watch); + const provider = this._providerService.getProviderForSession(session); + if (provider?.watchChatHistory || provider?.watchChatBackgroundShells) { + const watches = new DisposableStore(); + this._chatWatches.set(chat, watches); + const history = provider.watchChatHistory?.(chat); + if (history) { + watches.add(history); + } + const shells = provider.watchChatBackgroundShells?.(chat); + if (shells) { + watches.add(shells); + } } } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index a4b2792c82978b..cd3a950455424b 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -13,7 +13,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { structuralEquals } from '../../../../base/common/equals.js'; import { CancellationError, getErrorMessage } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableMap, DisposableSet, DisposableStore, type IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableResourceMap, DisposableSet, DisposableStore, type IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; import { equals } from '../../../../base/common/objects.js'; @@ -948,6 +948,7 @@ export class CopilotAgent extends Disposable implements IAgent { } private readonly _chatEntriesBySdkId = this._register(new DisposableMap()); + private readonly _backgroundShellWatches = this._register(new DisposableResourceMap>()); /** Sessions that may issue SDK callbacks before joining `_chatEntriesBySdkId`. */ private readonly _sessionsPendingRegistration = this._register(new DisposableSet()); private _connectorRefreshGeneration = 0; @@ -5865,6 +5866,24 @@ export class CopilotAgent extends Disposable implements IAgent { this._chatEntriesBySdkId.deleteAndDispose(session.sessionId); this._chatEntriesBySdkId.set(session.sessionId, this._createChatEntry(session, activeClient)); this._chatBackings.set(chat.toString(), { ...current, sdkSessionId: session.sessionId }); + const watch = this._backgroundShellWatches.get(chat); + if (watch) { + watch.value = session.observeBackgroundShells(); + } + } + + watchChatBackgroundShells(chat: URI): IDisposable { + const watch = new MutableDisposable(); + this._backgroundShellWatches.set(chat, watch); + const session = this._findChatByUri(chat); + if (session) { + watch.value = session.observeBackgroundShells(); + } + return toDisposable(() => { + if (this._backgroundShellWatches.get(chat) === watch) { + this._backgroundShellWatches.deleteAndDispose(chat); + } + }); } private _registerUnboundSession(session: CopilotAgentSession, activeClient: ActiveClient): void { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 1317448e071bf8..0af128d94b17c7 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -13,7 +13,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { Emitter } from '../../../../base/common/event.js'; import { CancellationError, getErrorMessage } from '../../../../base/common/errors.js'; import { escapeMarkdownSyntaxTokens } from '../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, IDisposable, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { LRUCache } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; import { isAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; @@ -60,7 +60,7 @@ import { buildNonPtyShellTerminalUri } from '../../common/nonPtyShellTerminalUri import { isHostSnapshotAttachment } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { ISessionDatabase, ISessionDataService, MAX_TERMINAL_OUTPUT_BYTES } from '../../common/sessionDataService.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; -import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment, type ToolCallContributor } from '../../common/state/protocol/state.js'; +import { BackgroundShellAttachmentMode, BackgroundShellStatus, MessageAttachmentKind, ToolCallContributorKind, type BackgroundShellInfo, type FileEdit, type MessageAttachment, type ToolCallContributor } from '../../common/state/protocol/state.js'; import { ActionType, isChatAction, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, isSubagentSession, parseRequiredSessionUriFromChatUri, type Customization, type Message, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type ToolResultTerminalContent, type Turn, type ITurnTokenTotal, type UsageInfo, type UsageInfoMeta, type IContextAttributionData, type ISessionPromptCacheState } from '../../common/state/sessionState.js'; import { IAgentConfigurationService, type IAgentSessionConfigurationChangeEvent } from '../agentConfigurationService.js'; @@ -901,8 +901,13 @@ export class CopilotAgentSession extends Disposable { private readonly _subagentTaskCompletionDelay: number; /** Bumped when a child starts a model round so a task snapshot taken earlier cannot complete it; entries are dropped on completion. */ private readonly _subagentActivityRevisions = new Map(); - private _subagentTaskStatusRevision = 0; - private readonly _subagentTaskStatusRefreshThrottler = this._register(new Throttler()); + private _backgroundTaskStatusRevision = 0; + private readonly _backgroundTaskStatusRefreshThrottler = this._register(new Throttler()); + private readonly _backgroundShells = new Map(); + private _backgroundShellObservers = 0; + private _republishBackgroundShells = false; + private _refreshDetachedBackgroundShells = false; + private readonly _backgroundShellRefresh = this._register(new RunOnceScheduler(() => this._refreshBackgroundTasks(true), 5000)); private readonly _unroutableSubagentToolCallIds = new Set(); private readonly _autoApprovals = new Map(); private readonly _pendingAutoApprovals = new PendingRequestRegistry(); @@ -1692,7 +1697,7 @@ export class CopilotAgentSession extends Disposable { } this._activeSubagentAgentIds.add(e.agentId); this._subagentObservedTokenUsage.set(parentToolCallId, new ObservedTokenUsage()); - this._subagentTaskStatusRevision++; + this._subagentActivityRevisions.set(e.agentId, (this._subagentActivityRevisions.get(e.agentId) ?? 0) + 1); this._onDidSessionProgress.fire({ kind: 'subagent_resumed', chat: this._chatChannelUri, @@ -1757,7 +1762,7 @@ export class CopilotAgentSession extends Disposable { const confirmation = new RunOnceScheduler(() => { // Confirm against the task list so a child that resumed meanwhile stays active. A superseded // or failed confirmation re-arms itself until the child is completed or running again. - this._reconcileSubagentTaskStatuses().then(settled => { + this._reconcileBackgroundTasks().then(settled => { if (!settled && this._subagentTaskCompletionSchedulers.get(agentId) === confirmation) { confirmation.schedule(); } @@ -1812,14 +1817,19 @@ export class CopilotAgentSession extends Disposable { } /** Resolves false when a newer reconcile superseded this one; the newest queued reconcile processes every task. */ - private _reconcileSubagentTaskStatuses(): Promise { - const revision = ++this._subagentTaskStatusRevision; - return this._subagentTaskStatusRefreshThrottler.queue(async () => { + private _reconcileBackgroundTasks(): Promise { + const revision = ++this._backgroundTaskStatusRevision; + return this._backgroundTaskStatusRefreshThrottler.queue(async () => { const activityRevisions = new Map(this._subagentActivityRevisions); + if (this._refreshDetachedBackgroundShells) { + this._refreshDetachedBackgroundShells = false; + await this._wrapper.session.rpc.tasks.refresh(); + } const tasks = await this._wrapper.session.rpc.tasks.list(); - if (this._store.isDisposed || revision !== this._subagentTaskStatusRevision) { + if (this._store.isDisposed || revision !== this._backgroundTaskStatusRevision) { return false; } + this._publishBackgroundShells(tasks.tasks); for (const task of tasks.tasks) { if (task.type !== 'agent') { continue; @@ -1846,12 +1856,65 @@ export class CopilotAgentSession extends Disposable { }); } - private _refreshSubagentTaskStatuses(): void { - void this._reconcileSubagentTaskStatuses().catch(err => { - this._logService.warn(`[Copilot:${this.sessionId}] Failed to reconcile subagent task status: ${getErrorMessage(err)}`); + private _refreshBackgroundTasks(refreshDetached = false): void { + this._refreshDetachedBackgroundShells ||= refreshDetached; + void this._reconcileBackgroundTasks().catch(err => { + this._logService.warn(`[Copilot:${this.sessionId}] Failed to reconcile background task status: ${getErrorMessage(err)}`); + if (!this._store.isDisposed && this._backgroundShellObservers > 0) { + this._backgroundShellRefresh.schedule(); + } + }); + } + + observeBackgroundShells(): IDisposable { + this._backgroundShellObservers++; + this._republishBackgroundShells = true; + this._refreshBackgroundTasks(true); + return toDisposable(() => { + if (--this._backgroundShellObservers === 0) { + this._backgroundShellRefresh.cancel(); + } }); } + private _publishBackgroundShells(tasks: Awaited>['tasks']): void { + const shells = new Map(); + for (const task of tasks) { + if (task.type !== 'shell' || task.executionMode === 'sync' || (task.status !== 'running' && task.status !== 'idle')) { + continue; + } + const shell: BackgroundShellInfo = { + id: task.id, + description: task.description, + command: task.command, + status: task.status === 'running' ? BackgroundShellStatus.Running : BackgroundShellStatus.Idle, + startedAt: task.startedAt, + attachmentMode: task.attachmentMode === 'detached' ? BackgroundShellAttachmentMode.Detached : BackgroundShellAttachmentMode.Attached, + }; + shells.set(shell.id, shell); + } + for (const id of this._backgroundShells.keys()) { + if (!shells.has(id)) { + this._emitAction({ type: ActionType.ChatBackgroundShellRemoved, shellId: id }); + this._backgroundShells.delete(id); + } + } + for (const [id, shell] of shells) { + if (this._republishBackgroundShells || !equals(this._backgroundShells.get(id), shell)) { + this._emitAction({ type: ActionType.ChatBackgroundShellSet, shell }); + this._backgroundShells.set(id, shell); + } + } + this._republishBackgroundShells = false; + if (this._backgroundShellObservers > 0 && [...shells.values()].some(shell => shell.attachmentMode === BackgroundShellAttachmentMode.Detached)) { + if (!this._backgroundShellRefresh.isScheduled()) { + this._backgroundShellRefresh.schedule(); + } + } else { + this._backgroundShellRefresh.cancel(); + } + } + private _directUsageFor(parentToolCallId: string | undefined, create: boolean): DirectUsageAccumulator | undefined { if (!parentToolCallId) { return this._currentTurn.value?.directUsage; @@ -3885,7 +3948,7 @@ export class CopilotAgentSession extends Disposable { } this._subagentTaskCompletionSchedulers.clearAndDisposeAll(); this._subagentActivityRevisions.clear(); - this._subagentTaskStatusRevision++; + this._backgroundTaskStatusRevision++; } const abortBarrier = this._abortBarrier ??= new DeferredPromise(); try { @@ -7825,7 +7888,7 @@ export class CopilotAgentSession extends Disposable { })); this._register(wrapper.onBackgroundTasksChanged(() => { - this._refreshSubagentTaskStatuses(); + this._refreshBackgroundTasks(); })); this._register(wrapper.onTurnStart(e => { @@ -7887,7 +7950,7 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onTurnEnd(e => { this._logService.trace(`[Copilot:${sessionId}] Turn ended: ${e.data.turnId}`); if (e.agentId) { - this._refreshSubagentTaskStatuses(); + this._refreshBackgroundTasks(); } const turn = this._currentTurn.value; if (!e.agentId && turn?.activeSdkTurnId === e.data.turnId) { diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 1f6a776f43d928..8158648dfe9123 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -13,6 +13,7 @@ import { NullLogService } from '../../../log/common/log.js'; import { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; import { ChangesetStatus, ChatInputQuestionKind, ChatInputResponseKind, ChatInteractivity, MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, createErrorResponsePart, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js'; import { type SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; +import { BackgroundShellAttachmentMode, BackgroundShellStatus } from '../../common/state/protocol/channels-chat/state.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { buildChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js'; import { withAgentCustomizationSettings } from '../../common/agentCustomizationSettings.js'; @@ -97,6 +98,45 @@ suite('AgentHostStateManager', () => { assert.strictEqual(snapshot, undefined); }); + test('background shells mirror into the session catalog and survive turn completion with chat-scoped IDs', () => { + manager.createSession(makeSessionSummary()); + const peer = buildChatUri(sessionUri, 'peer'); + manager.addChat(sessionUri, peer); + const shell = { + id: 'shared-id', description: 'Run tests', command: 'npm test', + status: BackgroundShellStatus.Running, startedAt: new Date(0).toISOString(), + attachmentMode: BackgroundShellAttachmentMode.Attached, + }; + const updates: string[] = []; + disposables.add(manager.onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.SessionChatUpdated && envelope.action.changes.backgroundShells) { + updates.push(envelope.action.chat); + } + })); + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatBackgroundShellSet, shell }); + manager.dispatchServerAction(peer, { type: ActionType.ChatBackgroundShellSet, shell: { ...shell, command: 'npm run build' } }); + manager.dispatchServerAction(sessionChatUri, { + type: ActionType.ChatTurnStarted, turnId: 'turn', startedAt: new Date(0).toISOString(), + message: { text: 'Follow-up', origin: { kind: MessageKind.User } }, + }); + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn', duration: 1 }); + const afterTurn = manager.getChatState(sessionChatUri)?.backgroundShells; + manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatBackgroundShellRemoved, shellId: shell.id }); + + assert.deepStrictEqual({ + afterTurn, + catalog: manager.getSessionState(sessionUri)?.chats.map(chat => ({ resource: chat.resource, shells: chat.backgroundShells })), + updates, + }, { + afterTurn: [shell], + catalog: [ + { resource: sessionChatUri, shells: [] }, + { resource: peer, shells: [{ ...shell, command: 'npm run build' }] }, + ], + updates: [sessionChatUri, peer, sessionChatUri], + }); + }); + test('getSnapshot returns root snapshot', () => { const snapshot = manager.getSnapshot(ROOT_STATE_URI); assert.ok(snapshot); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index f72d3258bc0880..0993240c3caab7 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -1340,6 +1340,32 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('observes background shells only after chat hydration and shares the observer across subscribers', async () => { + const hydrated: boolean[] = []; + let stops = 0; + const agent = new class extends MockAgent { + watchChatBackgroundShells(chat: URI) { + hydrated.push(!!getStateManager(service).getChatState(chat.toString())); + return toDisposable(() => stops++); + } + }('shells'); + disposables.add(toDisposable(() => agent.dispose())); + registerTestAgentProvider(service, agent); + const session = await service.createSession({ provider: 'shells' }); + const chat = URI.parse(buildDefaultChatUri(session)); + await service.subscribe(chat, 'first'); + await service.subscribe(chat, 'second'); + service.unsubscribe(chat, 'first'); + const afterFirst = stops; + service.unsubscribe(chat, 'second'); + await service.subscribe(chat, 'reconnected'); + service.unsubscribe(chat, 'reconnected'); + + assert.deepStrictEqual({ hydrated, afterFirst, stops }, { + hydrated: [true, true], afterFirst: 0, stops: 2, + }); + }); + test('starts catalog reconciliation after host startup and the first listing settle', async () => { registerTestAgentProvider(service, copilotAgent); const reconciliation = (service as unknown as { _catalogReconciliationService: { schedule(): void; start(): void } })._catalogReconciliationService; diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index b83b73175976f6..4b0b9e6559869a 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -1396,6 +1396,172 @@ suite('CopilotAgentSession', () => { teardown(() => disposables.clear()); ensureNoDisposablesAreLeakedInTestSuite(); + suite('background shells', () => { + function shell(id: string): Extract { + return { + type: 'shell', id, description: `Run ${id}`, command: 'npm test', + status: 'running', startedAt: new Date(0).toISOString(), + attachmentMode: 'attached', executionMode: 'background', + }; + } + + function shellActions(signals: readonly AgentSignal[]) { + return getActions(signals).filter(action => action.type === ActionType.ChatBackgroundShellSet || action.type === ActionType.ChatBackgroundShellRemoved); + } + + test('lists silent background shells across steering and removes completed commands', async () => { + const { session, mockSession, signals, waitForSignal } = await createAgentSession(disposables); + session.resetTurnState('original-turn'); + mockSession.backgroundTasks = [shell('silent')]; + mockSession.fire('session.background_tasks_changed', {}); + await waitForSignal(signal => isAction(signal, ActionType.ChatBackgroundShellSet)); + session.resetTurnState('steered-turn'); + mockSession.fire('session.background_tasks_changed', {}); + await timeout(0); + const afterSteering = shellActions(signals); + mockSession.backgroundTasks = [{ ...shell('silent'), status: 'completed' }]; + mockSession.fire('session.background_tasks_changed', {}); + await waitForSignal(signal => isAction(signal, ActionType.ChatBackgroundShellRemoved)); + + const { type: _type, executionMode: _mode, ...metadata } = shell('silent'); + assert.deepStrictEqual({ + afterSteering, + finished: shellActions(signals), + }, { + afterSteering: [{ type: ActionType.ChatBackgroundShellSet, shell: metadata }], + finished: [ + { type: ActionType.ChatBackgroundShellSet, shell: metadata }, + { type: ActionType.ChatBackgroundShellRemoved, shellId: 'silent' }, + ], + }); + }); + + test('publishes attached and detached active shells but not foreground or finished tasks', async () => { + const { mockSession, signals } = await createAgentSession(disposables); + mockSession.backgroundTasks = [ + shell('attached'), + { ...shell('detached'), status: 'idle', attachmentMode: 'detached' }, + { ...shell('foreground'), executionMode: 'sync' }, + { ...shell('completed'), status: 'completed' }, + { ...shell('failed'), status: 'failed' }, + { ...shell('cancelled'), status: 'cancelled' }, + ]; + mockSession.fire('session.background_tasks_changed', {}); + await timeout(0); + + assert.deepStrictEqual(shellActions(signals).flatMap(action => action.type === ActionType.ChatBackgroundShellSet + ? [{ id: action.shell.id, status: action.shell.status, attachmentMode: action.shell.attachmentMode }] + : []), [ + { id: 'attached', status: 'running', attachmentMode: 'attached' }, + { id: 'detached', status: 'idle', attachmentMode: 'detached' }, + ]); + }); + + test('shows a synchronous command once the runtime moves it to the background', async () => { + const { mockSession, signals, waitForSignal } = await createAgentSession(disposables); + mockSession.backgroundTasks = [{ ...shell('timed-out'), executionMode: 'sync' }]; + mockSession.fire('session.background_tasks_changed', {}); + await timeout(0); + const whileForeground = shellActions(signals); + mockSession.backgroundTasks = [shell('timed-out')]; + mockSession.fire('session.background_tasks_changed', {}); + await waitForSignal(signal => isAction(signal, ActionType.ChatBackgroundShellSet)); + assert.deepStrictEqual({ + whileForeground, + afterTimeout: shellActions(signals).flatMap(action => action.type === ActionType.ChatBackgroundShellSet ? [action.shell.id] : []), + }, { whileForeground: [], afterTimeout: ['timed-out'] }); + }); + + test('refreshes and republishes the shell inventory when a restored chat is observed again', async () => { + const { session, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { + resume: true, + configureMockSession: mock => { mock.backgroundTasks = [shell('restored')]; }, + }); + const first = disposables.add(session.observeBackgroundShells()); + await waitForSignal(signal => isAction(signal, ActionType.ChatBackgroundShellSet)); + first.dispose(); + disposables.add(session.observeBackgroundShells()); + await timeout(0); + + assert.deepStrictEqual({ + publishedIds: shellActions(signals).flatMap(action => action.type === ActionType.ChatBackgroundShellSet ? [action.shell.id] : []), + refreshes: mockSession.backgroundTaskRefreshCalls, + }, { publishedIds: ['restored', 'restored'], refreshes: 2 }); + }); + + test('does not publish a stale shell snapshot after a newer change', async () => { + const { mockSession, signals, waitForSignal } = await createAgentSession(disposables); + const gate = new DeferredPromise(); + mockSession.backgroundTasks = [shell('stale')]; + mockSession.backgroundTaskListGates.push(gate.p); + mockSession.fire('session.background_tasks_changed', {}); + await timeout(0); + mockSession.backgroundTasks = [shell('current')]; + mockSession.fire('session.background_tasks_changed', {}); + await gate.complete(); + await waitForSignal(signal => isAction(signal, ActionType.ChatBackgroundShellSet)); + + assert.deepStrictEqual(shellActions(signals).flatMap(action => action.type === ActionType.ChatBackgroundShellSet ? [action.shell.id] : []), ['current']); + }); + + test('preserves the inventory on failed reads and reconciles on the next change', async () => { + const { mockSession, signals, waitForSignal } = await createAgentSession(disposables); + mockSession.backgroundTasks = [shell('running')]; + mockSession.fire('session.background_tasks_changed', {}); + await waitForSignal(signal => isAction(signal, ActionType.ChatBackgroundShellSet)); + mockSession.backgroundTaskListError = new Error('temporary task-list failure'); + mockSession.backgroundTasks = []; + mockSession.fire('session.background_tasks_changed', {}); + await timeout(0); + const afterFailure = shellActions(signals).map(action => action.type); + mockSession.fire('session.background_tasks_changed', {}); + await waitForSignal(signal => isAction(signal, ActionType.ChatBackgroundShellRemoved)); + + assert.deepStrictEqual({ + afterFailure, + afterRetry: shellActions(signals).map(action => action.type), + }, { + afterFailure: [ActionType.ChatBackgroundShellSet], + afterRetry: [ActionType.ChatBackgroundShellSet, ActionType.ChatBackgroundShellRemoved], + }); + }); + + test('does not publish a shell read that finishes after disposal', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + const gate = new DeferredPromise(); + mockSession.backgroundTasks = [shell('late')]; + mockSession.backgroundTaskListGates.push(gate.p); + mockSession.fire('session.background_tasks_changed', {}); + await timeout(0); + session.dispose(); + await gate.complete(); + await timeout(0); + assert.deepStrictEqual(shellActions(signals), []); + }); + + test('refreshes detached shell completion without SDK events and stops polling when unobserved', () => runWithFakedTimers({}, async () => { + const { session, mockSession, signals, waitForSignal } = await createAgentSession(disposables); + mockSession.backgroundTasks = [{ ...shell('detached'), attachmentMode: 'detached' }]; + const observer = disposables.add(session.observeBackgroundShells()); + await waitForSignal(signal => isAction(signal, ActionType.ChatBackgroundShellSet)); + mockSession.backgroundTasks = []; + await timeout(5001); + observer.dispose(); + const refreshes = mockSession.backgroundTaskRefreshCalls; + await timeout(5001); + + assert.deepStrictEqual({ + actions: shellActions(signals).map(action => action.type), + refreshes, + afterUnobserve: mockSession.backgroundTaskRefreshCalls, + }, { + actions: [ActionType.ChatBackgroundShellSet, ActionType.ChatBackgroundShellRemoved], + refreshes: 2, + afterUnobserve: 2, + }); + })); + }); + test('initializes customization enablement before launching the SDK session', async () => { let initialized = false; await createAgentSession(disposables, { diff --git a/src/vs/sessions/contrib/chat/browser/sessionBackgroundShellsControl.ts b/src/vs/sessions/contrib/chat/browser/sessionBackgroundShellsControl.ts new file mode 100644 index 00000000000000..3cd4db1e032e1a --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionBackgroundShellsControl.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, append } from '../../../../base/browser/dom.js'; +import { RunOnceScheduler } from '../../../../base/common/async.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { getDurationString } from '../../../../base/common/date.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derived, IObservable, observableFromEvent } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import type { IChatPillEntry, IChatPillSection } from '../../../../workbench/browser/chatPills.js'; +import type { IChat, IChatBackgroundShell } from '../../../services/sessions/common/session.js'; + +function createShellDetails() { + const element = $('.chat-pill-location-hover'); + return { + element, + status: append(element, $('div')), + command: append(element, $('div')), + shellId: append(element, $('div')), + startedAt: append(element, $('div')), + }; +} + +export class SessionBackgroundShellsControl extends Disposable { + + readonly sections: IObservable; + // Stable detail nodes let the picker preserve the open panel across clock ticks. + private readonly _details = new Map>(); + private _currentChat: IChat | undefined; + + constructor( + chat: IObservable, + ) { + super(); + const now = observableFromEvent(this, listener => { + const scheduler = new RunOnceScheduler(() => { + scheduler.schedule(); + listener(undefined); + }, 1000); + scheduler.schedule(); + return scheduler; + }, () => Date.now()); + + this.sections = derived(this, reader => { + const currentChat = chat.read(reader); + if (currentChat !== this._currentChat) { + this._currentChat = currentChat; + this._details.clear(); + } + const shells = currentChat?.backgroundShells?.read(reader) ?? []; + const shellIds = new Set(shells.map(shell => shell.id)); + for (const id of this._details.keys()) { + if (!shellIds.has(id)) { + this._details.delete(id); + } + } + if (shells.length === 0) { + return []; + } + const timestamp = now.read(reader); + return [{ + title: localize('backgroundShells.active', "Active background shells"), + entries: shells.map(shell => this._entry(shell, timestamp)), + }]; + }); + } + + private _entry(shell: IChatBackgroundShell, now: number): IChatPillEntry { + const name = shell.description.trim() || shell.command; + const activity = shell.status === 'running' + ? localize('backgroundShells.running', "Running") + : localize('backgroundShells.waiting', "Waiting"); + const attachment = shell.attachmentMode === 'detached' + ? localize('backgroundShells.detached', "Detached") + : localize('backgroundShells.attached', "Attached"); + const startedAt = Date.parse(shell.startedAt); + const badge = Number.isFinite(startedAt) + ? localize('backgroundShells.statusWithDuration', "{0}, {1}, {2}", activity, attachment, getDurationString(Math.max(0, Math.floor((now - startedAt) / 1000) * 1000))) + : localize('backgroundShells.status', "{0}, {1}", activity, attachment); + const detail = localize('backgroundShells.details', "{0}\n\nCommand: {1}\nShell ID: {2}\nStarted: {3}", badge, shell.command, shell.id, shell.startedAt); + const content = this._details.get(shell.id) ?? createShellDetails(); + this._details.set(shell.id, content); + for (const [element, text] of [ + [content.status, badge], + [content.command, localize('backgroundShells.command', "Command: {0}", shell.command)], + [content.shellId, localize('backgroundShells.id', "Shell ID: {0}", shell.id)], + [content.startedAt, localize('backgroundShells.startedAt', "Started: {0}", shell.startedAt)], + ] as const) { + if (element.textContent !== text) { + element.textContent = text; + } + } + return { + id: shell.id, + label: name, + icon: Codicon.terminal, + badge, + ariaLabel: localize('backgroundShells.showDetails', "Show details for background shell {0}", name), + ariaDescription: detail, + hover: { + content: content.element, + expandable: true, + alignToParentBottom: true, + panelClassName: 'chat-pill-location-hover-panel', + }, + open: () => { }, + }; + } + + override dispose(): void { + this._details.clear(); + this._currentChat = undefined; + super.dispose(); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index 18725abcce8518..1d1a1413028511 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -42,6 +42,7 @@ import { IActiveSession, ISessionsManagementService } from '../../../services/se import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { logSessionArtifactOpen } from '../../../common/sessionsTelemetry.js'; import { SessionBackgroundActivitiesControl } from './sessionBackgroundActivitiesControl.js'; +import { SessionBackgroundShellsControl } from './sessionBackgroundShellsControl.js'; import { SessionBrowsersControl } from './sessionBrowsersControl.js'; import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug.js'; import { SessionActivatingActionRunner } from '../../../browser/sessionActionRunner.js'; @@ -400,6 +401,7 @@ export class SessionChatInputToolbar extends Disposable { const pillsVisible = derived(this, reader => this._debugData.read(reader) !== undefined || !this._isSubagentChat.read(reader)); this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, pillsEnabled, constObservable(true))); + const backgroundShells = this._register(instantiationService.createInstance(SessionBackgroundShellsControl, this._chat)); const pullRequestRefs = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => gitHubReferences.read(reader).pullRequests); const agentMergeConfiguration = derived(this, reader => { const session = this._session.read(reader); @@ -523,6 +525,7 @@ export class SessionChatInputToolbar extends Disposable { customizations: { sections: this._customizationSections }, browsers: { sections: this._browsers.sections }, subagents: this._backgroundActivities, + backgroundShells, }, SESSION_CHAT_PILL_KINDS)); const actionRunner = this._register(new SessionActivatingActionRunner(() => this._session.get(), this._sessionsService)); this._inputPills = this._register(instantiationService.createInstance(ChatInputPills, undefined, { diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index cc33913fbaf33f..4f22a92dd1f52a 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -134,6 +134,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.pastedText', "Long pasted text is stored as an attached text item and replaced in the input with a numbered inline reference.")); content.push(localize('sessionsChat.pasteAsText', "To paste the clipboard as plain text, without converting it to Markdown or storing it as an attachment, invoke Paste as Text{0}.", '')); content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach metadata and status pills above it, use the left and right arrows to move between pills, and press Enter or Space to activate one. Live browsers appear in their own pill, and the chat's subagents appear in another. A pill with more than one entry opens a picker. Subagents are grouped under Subagents: In Progress and Subagents: Completed. In Progress includes subagents waiting for input; Completed includes failed subagents. Use the up and down arrows to move between entries. When an entry has details, Tab moves through its row actions and detail links; Shift+Tab returns to the row action, and the up and down arrows continue moving between entries. Press Enter to open an entry, or Escape to dismiss the picker and return focus to the pill.")); + content.push(localize('sessionsChat.backgroundShells', "The Background Shells pill opens a picker above the chat input, including for a single shell. Each entry includes its running or waiting status, attached or detached mode, and elapsed time. Use the arrow keys to choose a shell, then Enter or Right Arrow to open its live command details beside the picker. Left Arrow or Escape returns to the list; Escape from the list returns focus to the pill. Elapsed time continues updating while details are open, and a shell disappears when it finishes. This list does not stop commands or stream their output.")); const chatTabsMode = configurationService.getValue(SESSIONS_CHAT_TABS_SETTING) ?? SESSIONS_CHAT_TABS_DEFAULT; content.push(chatTabsMode === SessionsChatTabsMode.Single ? localize('sessionsChat.conversationsAsSessionView', "Chats open directly in the session view without a tab row. Side-by-side chats each show a session header. Pin keeps that chat visible when another chat opens. Close removes that chat group. Closing the last group closes the session from the grid. Non-main chats are hidden and can be reopened later. For sessions that support multiple chats, use Show Chat Tabs in the session overflow menu to show multiple tabs.") diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionBackgroundShellsControl.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionBackgroundShellsControl.test.ts new file mode 100644 index 00000000000000..01f791d2e7abf4 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/sessionBackgroundShellsControl.test.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { isHTMLElement } from '../../../../../base/browser/dom.js'; +import { timeout } from '../../../../../base/common/async.js'; +import { autorun, constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/virtualScheduling/index.js'; +import type { IChat, IChatBackgroundShell } from '../../../../services/sessions/common/session.js'; +import { SessionBackgroundShellsControl } from '../../browser/sessionBackgroundShellsControl.js'; + +suite('SessionBackgroundShellsControl', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('updates elapsed time while observed and stops when the list becomes empty', () => runWithFakedTimers({}, async () => { + const shells = observableValue('shells', [{ + id: 'running', description: 'Run tests', command: 'npm test', + status: 'running', startedAt: new Date(0).toISOString(), attachmentMode: 'attached', + }]); + const chat = new class extends mock() { + override readonly backgroundShells = shells; + }(); + const control = store.add(new SessionBackgroundShellsControl(constObservable(chat))); + let badge: string | undefined; + const observer = store.add(autorun(reader => { + badge = control.sections.read(reader)[0]?.entries[0].badge; + })); + const initial = badge; + const details = control.sections.get()[0].entries[0].hover?.content; + assert.ok(isHTMLElement(details)); + const initialDetails = details.firstElementChild?.textContent; + await timeout(2100); + const elapsed = badge; + const elapsedDetails = details.firstElementChild?.textContent; + const sameDetails = control.sections.get()[0].entries[0].hover?.content === details; + shells.set([], undefined); + await timeout(2000); + observer.dispose(); + + assert.deepStrictEqual({ initial, elapsed, initialDetails, elapsedDetails, sameDetails, empty: badge }, { + initial: 'Running, Attached, 0ms', + elapsed: 'Running, Attached, 2s', + initialDetails: 'Running, Attached, 0ms', + elapsedDetails: 'Running, Attached, 2s', + sameDetails: true, + empty: undefined, + }); + })); + + test('updates shell rows independently of turns and exposes the full command as plain text', () => { + const shell: IChatBackgroundShell = { + id: 'silent', description: 'Run tests', command: 'npm test -- --grep "