src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md
This document describes the architecture of the Agent Sessions Chat Widget (AgentSessionsChatWidget), a new extensible chat widget designed for the agent sessions window. It replaces the tightly-coupled agent session logic inside ChatWidget and ChatInputPart with a clean, composable system built around the wrapper pattern.
The original approach to supporting agent sessions involved adding agent-specific logic directly into the core ChatWidget and ChatInputPart. Over time, this led to significant coupling and code complexity:
Inside ChatWidget (~100+ lines of agent-specific code):
ChatFullWelcomePart is directly instantiated inside ChatWidget.render(), with the widget reaching into the welcome part's DOM to reparent the input element between fullWelcomePart.inputSlot and mainInputContainershowFullWelcome creates a forked rendering path — 5+ conditional branches in render(), updateChatViewVisibility(), and renderWelcomeViewContentIfNeeded()lockToCodingAgent() / unlockFromCodingAgent() add ~55 lines of method code plus ~20 lines of scattered _lockedAgent-gated logic throughout clear(), forcedAgent computation, welcome content generation, and scroll lock behavior_lockedToCodingAgentContextKey context key is set/read in many places, creating implicit coupling between agent session state and widget renderingInside ChatInputPart (~50+ lines of agent-specific code):
AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderName directly_pendingDelegationTarget, agentSessionTypeKey context key, and sessionTargetWidgetsessionTypePickerDelegate?.getActiveSessionProvider() to determine option groups, picker rendering, and session type handlinggetEffectiveSessionType() resolves session type through a delegate → session context → fallback chainConsequences:
ChatWidget internals, risking regressions in the standard chat experienceThe AgentSessionsChatWidget wraps ChatWidget instead of patching it. Agent-specific behavior lives in self-contained components that compose with the core widget through well-defined interfaces (submitHandler, hiddenPickerIds, excludeOptionGroup, ISessionTypePickerDelegate bridge). The core ChatWidget requires no agent-specific modifications.
The Agent Sessions Chat Widget provides:
graph TD
A[AgentSessionsChatWidget] --> B[ChatWidget]
A --> C[AgentSessionsChatWelcomePart]
A --> D[AgentSessionsChatTargetConfig]
B --> F[ChatInputPart]
C --> G[Target Buttons]
C --> H[Option Pickers]
C --> I[Input Slot]
D --> J[Observable Target State]
E[AgentSessionsChatInputPart] -.->|standalone adapter| F
E -.-> D
Note:
AgentSessionsChatInputPartis a standalone adapter that bridgesIAgentChatTargetConfigtoChatInputPart. It is available for consumers that need aChatInputPartoutside of a fullChatWidget, butAgentSessionsChatWidgetitself creates the bridge delegate inline and passes it throughwrappedViewOptionsto theChatWidget's ownChatInputPart.
AgentSessionsChatWidgetLocation: src/vs/sessions/browser/widget/agentSessionsChatWidget.ts
The main wrapper around ChatWidget. It:
AgentSessionsChatTargetConfig from provided optionssubmitHandler to create the session on first send, and monkey-patches acceptInput to attach initialSessionOptions to the session contextAgentSessionsChatWelcomePart when the chat is emptyhiddenPickerIds and excludeOptionGroup to avoid showing pickers in both the welcome view and input toolbarStorageService so pickers render immediately on next load before extensions activateThe widget uses two complementary interception points:
submitHandler (in wrappedViewOptions): Called by ChatWidget._acceptInput() before the normal send flow. If the session hasn't been created yet, it calls _createSessionForCurrentTarget(), restores the input text (which gets cleared by setModel()), and returns false to let the normal flow continue.acceptInput: Called when ChatSubmitAction directly invokes chatWidget.acceptInput(). This captures the input text, creates the session if needed, then calls _gatherAllOptionSelections() to merge all option picks and attaches them to contributedChatSession.initialSessionOptions before delegating to the original acceptInput.Both paths converge on the same session creation and option gathering logic. The submitHandler handles the ChatWidget-internal send path, while the monkey-patch handles external callers (like ChatSubmitAction).
sequenceDiagram
participant User
participant Widget as AgentSessionsChatWidget
participant Welcome as AgentSessionsChatWelcomePart
participant ChatWidget
participant Extension
User->>Widget: Opens agent sessions window
Widget->>ChatWidget: render() with wrappedViewOptions
Widget->>Welcome: Create welcome view
Welcome-->>User: Shows mascot + target buttons + input
User->>Welcome: Selects "Cloud" target
Welcome->>Widget: targetConfig.setSelectedTarget(Cloud)
Widget->>ChatWidget: Bridge to sessionTypePickerDelegate
User->>Welcome: Picks repository
Welcome-->>Welcome: Stores in _selectedOptions
User->>ChatWidget: Types message + clicks Send
Note over ChatWidget: ChatSubmitAction calls acceptInput()
ChatWidget->>Widget: Monkey-patched acceptInput()
Widget->>Widget: Capture input text
Widget->>Widget: _createSessionForCurrentTarget()
Widget->>Widget: _gatherAllOptionSelections()
Widget->>ChatWidget: setContributedChatSession({...initialSessionOptions})
Widget->>ChatWidget: originalAcceptInput(capturedInput)
Note over ChatWidget: _acceptInput() → submitHandler check (already created, returns false)
ChatWidget->>Extension: $invokeAgent(request, {chatSessionContext: {initialSessionOptions}})
AgentSessionsChatTargetConfigLocation: src/vs/sessions/browser/widget/agentSessionsChatTargetConfig.ts
A reactive configuration object that tracks:
[Background, Cloud])interface IAgentChatTargetConfig {
readonly allowedTargets: IObservable<ReadonlySet<AgentSessionProviders>>;
readonly selectedTarget: IObservable<AgentSessionProviders | undefined>;
readonly onDidChangeSelectedTarget: Event<AgentSessionProviders | undefined>;
readonly onDidChangeAllowedTargets: Event<ReadonlySet<AgentSessionProviders>>;
setSelectedTarget(target: AgentSessionProviders): void;
addAllowedTarget(target: AgentSessionProviders): void;
removeAllowedTarget(target: AgentSessionProviders): void;
setAllowedTargets(targets: AgentSessionProviders[]): void;
}
The target config is purely UI state — changing targets does NOT create sessions or resources.
AgentSessionsChatWelcomePartLocation: src/vs/sessions/browser/parts/agentSessionsChatWelcomePart.ts
Renders the welcome view when the chat is empty:
The welcome part reads from IAgentChatTargetConfig and the IChatSessionsService for option groups.
AgentSessionsChatInputPartLocation: src/vs/sessions/browser/parts/agentSessionsChatInputPart.ts
A standalone adapter around ChatInputPart that bridges IAgentChatTargetConfig to the existing ISessionTypePickerDelegate interface. It creates a createTargetConfigDelegate() bridge so the standard ChatInputPart can work with the new target config system without modifications.
Important: AgentSessionsChatWidget does not use this adapter directly. Instead, it creates its own bridge delegate inline and passes it to ChatWidget via wrappedViewOptions.sessionTypePickerDelegate. The AgentSessionsChatInputPart is available for consumers that need a ChatInputPart with target config integration outside the context of a full ChatWidget (e.g., a detached input field).
AgentSessionsTargetPickerActionItemLocation: src/vs/sessions/browser/widget/agentSessionsTargetPickerActionItem.ts
A dropdown picker action item for the input toolbar that reads available targets from IAgentChatTargetConfig (rather than chatSessionsService). Selection calls targetConfig.setSelectedTarget() with no session creation side effects. It renders the current target's icon and name, with a chevron to open the dropdown of allowed targets. The picker automatically re-renders when the selected target or allowed targets change.
The chat input behaves differently depending on whether it's the very first load (before the extension activates) or a subsequent "New Session" after the extension is already active.
When the agent sessions window opens for the first time:
ChatWidget renders with no model — viewModel is undefinedChatInputPart has no sessionResource, so pickers query the sessionTypePickerDelegate for the effective session typechatSessionHasModels context key is false (no option groups registered)lockedToCodingAgent is false (contribution not available yet)ChatSessionPrimaryPickerAction menu item is hidden (its when clause requires both)_generatePendingSessionResource() generates a lightweight URI (e.g., copilotcli:/untitled-<uuid>) synchronously. No async work or extension activation needed. This resource allows picker commands and notifySessionOptionsChange events to flow through the existing pipeline.onDidChangeAvailability fires → updateWidgetLockStateFromSessionType sets lockedToCodingAgent = trueonDidChangeOptionGroups fires with fresh data → chatSessionHasModels = truewhen clause is now satisfied → toolbar re-renders with the picker actionnotifySessionOptionsChange with the pending resource — the service stores values in _pendingSessionOptions, fires onDidChangeSessionOptions, and the welcome part and ChatInputPart match the resource and sync picker state.State: No viewModel, _pendingSessionResource is set immediately (sync)
ChatInputPart: Uses delegate.getActiveSessionProvider() for session type
Pickers: Initially hidden, appear when extension activates
Option groups: Cached from storage → overwritten by extension
Session options: Stored in lightweight _pendingSessionOptions map (no ContributedChatSessionData)
When the user clicks "New Session" after completing a request:
resetSession() is calledsetModel(undefined) and the model ref is disposed_sessionCreated is reset to false_pendingSessionResource is clearedChatInputPart are cleared via takePendingOptionSelections()resetSelectedOptions()_generatePendingSessionResource() generates a fresh pending resource (synchronous)ChatWidget again has no model — same as first load from the input's perspectivelockedToCodingAgent is already true (contribution is available)chatSessionHasModels is already true (option groups are registered)getOrCreateChatSession resolves quickly since the content provider is already registeredState: No viewModel, _pendingSessionResource set after init
ChatInputPart: Uses delegate.getActiveSessionProvider() for session type
Pickers: Render immediately (extension already active, context keys already set)
Option groups: Live data from extension (already registered)
| Aspect | First Load | New Session |
|---|---|---|
| Extension state | Not activated | Already active |
lockedToCodingAgent | false → true (async) | Already true |
chatSessionHasModels | false → true (async) | Already true |
| Input toolbar pickers | Hidden → appear on activation | Visible immediately |
| Welcome part pickers | Cached → replaced with live data | Live data from start |
| Session resource | Generated as pending, session data created eagerly | Old cleared, new pending generated |
_pendingSessionResource | Set after getOrCreateChatSession completes | Cleared and re-initialized |
_pendingOptionSelections | Empty | Cleared via takePendingOptionSelections() |
| Extension option changes | Received after pending init completes | Received immediately |
locked Flag and Session ResetExtensions can mark option items as locked (e.g., locking the folder picker after a request starts). This is a session-specific concept:
notifySessionOptionsChange with { ...option, locked: true }syncOptionsFromSession, but strips the locked flag before storing in _selectedOptionsChatSessionPickerActionItem widget's currentOption.locked check, which disables the dropdownTraditional chat sessions require a session resource (URI) to exist before the user can interact. This means:
The Agent Sessions Chat Widget defers chat model creation to the moment of first submit, but eagerly initializes session data so extensions can interact with options before the user sends a message:
stateDiagram-v2
[*] --> PendingSession: Widget renders
PendingSession --> PendingSession: getOrCreateChatSession (session data only)
PendingSession --> PendingSession: Extension fires notifySessionOptionsChange
PendingSession --> PendingSession: User selects target
PendingSession --> PendingSession: User picks options
PendingSession --> PendingSession: User types message
PendingSession --> SessionCreated: User clicks Send
SessionCreated --> Active: Chat model created + model set
Active --> Active: Subsequent sends go through normally
Active --> PendingSession: User clears/resets
Before chat model creation (pending session state):
getResourceForNewChatSession() and chatSessionsService.getOrCreateChatSession() is called eagerly. This creates session data (options store) and invokes provideChatSessionContent so the extension knows the resource.notifySessionOptionsChange(pendingResource, updates) at any time — the welcome part matches the pending resource and syncs option values.AgentSessionsChatTargetConfig_pendingOptionSelections (ChatInputPart) and _selectedOptions (welcome part), AND forwarded to the extension via notifySessionOptionsChange using the pending resource.At chat model creation (triggered by either submitHandler or the patched acceptInput):
_createSessionForCurrentTarget() reads the current target from the configloadSessionForResource(resource, location, CancellationToken.None) which reuses the existing session data from getOrCreateChatSession(); for local targets, calls startSession(location) directlyChatWidget via setModel() (this clears the input editor, so the input text is captured and restored)_gatherAllOptionSelections() collects options from welcome part + input toolbarcontributedChatSession.initialSessionOptions via model.setContributedChatSession()ChatWidget._acceptInput flowinitialSessionOptions)When a session is created on first submit, the extension needs to know what options the user selected (model, repository, agent, etc.). But the traditional provideHandleOptionsChange mechanism is async and fire-and-forget — there's no guarantee the extension processes it before the request arrives.
Options travel atomically with the first request via initialSessionOptions on the ChatSessionContext:
flowchart LR
A[Welcome Part
_selectedOptions] -->|merge| C[_gatherAllOptionSelections]
B[Input Toolbar
_pendingOptionSelections] -->|merge| C
C --> D[model.setContributedChatSession
initialSessionOptions]
D --> E[mainThreadChatAgents2
serialize to DTO]
E --> F[extHostChatAgents2
pass to extension]
F --> G[Extension handler
reads from context]
| Layer | Type | Field |
|---|---|---|
| Internal model | IChatSessionContext | initialSessionOptions?: ReadonlyArray<{optionId: string, value: string | { id: string; name: string }}> |
| Protocol DTO | IChatSessionContextDto | initialSessionOptions?: ReadonlyArray<{optionId: string, value: string}> |
| Extension API | ChatSessionContext | initialSessionOptions?: ReadonlyArray<{optionId: string, value: string}> |
Note: The internal model allows
valueto be either astringor{ id, name }(matchingIChatSessionProviderOptionItem's structural type). During serialization to the protocol DTO inmainThreadChatAgents2, the value is converted tostring. The extension always receivesstringvalues.
// In the extension's request handler:
async handleRequest(request, context, stream, token) {
const { chatSessionContext } = context;
// ⚠️ IMPORTANT: Apply initial options BEFORE any code that reads
// folder/model/agent state (e.g., lockRepoOption, hasUncommittedChanges).
// The initialSessionOptions override defaults set by provideChatSessionContent.
const initialOptions = chatSessionContext?.initialSessionOptions;
if (initialOptions) {
for (const { optionId, value } of initialOptions) {
// Apply options to internal state
if (optionId === 'model') { setModel(value); }
if (optionId === 'repository') { setRepository(value); }
}
}
// Now downstream reads (trust checks, uncommitted changes, etc.)
// see the correct options.
// ...
}
When _gatherAllOptionSelections() merges options:
Pickers can appear in two locations:
AgentSessionsChatWelcomePartChatInputPartTo avoid duplication, the widget uses two mechanisms:
hiddenPickerIdsHides entire picker actions from the input toolbar:
hiddenPickerIds: new Set([
OpenSessionTargetPickerAction.ID, // Target picker in welcome
OpenModePickerAction.ID, // Mode picker hidden
OpenModelPickerAction.ID, // Model picker hidden
ConfigureToolsAction.ID, // Tools config hidden
])
excludeOptionGroupSelectively excludes specific option groups from ChatSessionPrimaryPickerAction in the input toolbar while keeping others:
excludeOptionGroup: (group) => {
const idLower = group.id.toLowerCase();
const nameLower = group.name.toLowerCase();
// Repository/folder pickers are in the welcome view
return idLower === 'repositories' || idLower === 'folders' ||
nameLower === 'repository' || nameLower === 'repositories' ||
nameLower === 'folder' || nameLower === 'folders';
}
This allows the input toolbar to still show model pickers from ChatSessionPrimaryPickerAction while the welcome view handles the repository picker.
graph TB
subgraph "Welcome View (above input)"
T[Target Buttons
Local | Cloud]
R[Repository Picker
via excludeOptionGroup]
end
subgraph "Input Toolbar (inside input)"
M[Model Picker
from ChatSessionPrimaryPickerAction]
S[Send Button]
end
subgraph "Hidden from toolbar"
H1[Target Picker
hiddenPickerIds]
H2[Mode Picker
hiddenPickerIds]
end
src/vs/sessions/browser/widget/
├── AGENTS_CHAT_WIDGET.md # This document
├── agentSessionsChatWidget.ts # Main widget wrapper
├── agentSessionsChatTargetConfig.ts # Target configuration (observable)
├── agentSessionsTargetPickerActionItem.ts # Target picker for input toolbar
└── media/
└── agentSessionsChatWidget.css # Widget-specific styles
src/vs/sessions/browser/parts/
├── agentSessionsChatInputPart.ts # Input part adapter
└── agentSessionsChatWelcomePart.ts # Welcome view (mascot + pickers)
To add a new agent provider (e.g., "Codex"):
AgentSessionProviders enum in agentSessions.tschatViewPane.ts:
allowedTargets: [Background, Cloud, Codex]
initialSessionOptions in the extension's request handlerprovideChatSessionProviderOptionsThe welcome part and input toolbar automatically pick up new targets and option groups.
| Aspect | Old (ChatFullWelcomePart inside ChatWidget) | New (AgentSessionsChatWidget wrapper) |
|---|---|---|
| Session creation | Eager (on load) | Deferred (on first send) |
| Target selection | ISessionTypePickerDelegate callback | IAgentChatTargetConfig observable |
| Option delivery | provideHandleOptionsChange (async, fire-and-forget) | initialSessionOptions (atomic with request) |
| Welcome view | Inside ChatWidget via showFullWelcome flag | Separate AgentSessionsChatWelcomePart |
| Picker placement | Hardcoded in ChatInputPart | Configurable via hiddenPickerIds + excludeOptionGroup |
| Input reparenting | ChatWidget reaches into welcome part's DOM | AgentSessionsChatWidget manages its own DOM layout |
| Agent lock state | lockToCodingAgent() / unlockFromCodingAgent() on ChatWidget | Not needed — target config is external state |
| Extensibility | Requires modifying ChatWidget internals | Self-contained, composable components |
1. Clean Separation of Concerns
The old approach embeds agent session logic (target selection, welcome view, lock state, option caching) directly inside ChatWidget. This means every agent feature touches the same file that powers the standard chat experience. The new architecture keeps ChatWidget focused on its core responsibility — rendering a chat conversation — and pushes agent-specific behavior into dedicated components.
2. Reduced Risk of Regressions
In the old architecture, ChatWidget.render() has forked control flow gated on showFullWelcome, and ChatInputPart has ~15 call sites checking session type delegates. A change to how pickers render could break the standard chat. In the new architecture, AgentSessionsChatWidget composes with ChatWidget through stable, narrow interfaces (submitHandler, hiddenPickerIds, excludeOptionGroup), so changes to agent session behavior cannot break the core widget.
3. Testable in Isolation
AgentSessionsChatTargetConfig can be unit-tested independently — it's a pure observable state container with no DOM or service dependencies beyond Disposable. The old ISessionTypePickerDelegate was an ad-hoc callback interface defined inline, making it harder to mock and test.
4. Deferred Session Creation
The old architecture creates sessions eagerly, requiring an async round-trip to the extension before the UI is usable. The new architecture lets the user interact immediately (type, select targets, pick options) and only creates the session on first send. This eliminates the loading state and makes the initial experience feel instant.
5. Atomic Option Delivery
The old provideHandleOptionsChange mechanism sends option changes asynchronously — if the user changes a repository picker and immediately sends a message, there's a race condition where the extension might not have processed the option change yet. The new initialSessionOptions mechanism bundles all option selections with the first request, guaranteeing the extension sees the correct state.
6. Easier to Add New Agent Providers
Adding a new provider in the old architecture requires modifying ChatWidget, ChatInputPart, and ChatFullWelcomePart. In the new architecture, it's a matter of adding to the AgentSessionProviders enum and updating the allowedTargets config — the welcome part and input toolbar automatically discover new targets and option groups.
7. No Core Widget Modifications Required
The entire agent sessions feature works by wrapping ChatWidget with composition hooks that ChatWidget already exposes (submitHandler, viewOptions). This means the agent sessions team can iterate independently without coordinating changes to shared core widget code.