From 96b10037d124291a558132f83dce7806de3f0561 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:35:03 -0700 Subject: [PATCH 1/6] better padding for reasoning when we have persistent progress (#311272) --- .../widget/chatContentParts/chatThinkingContentPart.ts | 5 +++++ .../widget/chatContentParts/media/chatThinkingContent.css | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 048bcd76e1fba..05a004c927299 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -359,6 +359,9 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen if (this.fixedScrollingMode) { node.classList.add('chat-thinking-fixed-mode'); + if (!this.streamingCompleted && !this.element.isComplete && this.showProgressDetails) { + node.classList.add('chat-thinking-persistent-streaming'); + } this.currentTitle = this.defaultTitle; } @@ -946,6 +949,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.wrapper.classList.remove('chat-thinking-streaming'); } this.domNode.classList.remove('chat-thinking-active'); + this.domNode.classList.remove('chat-thinking-persistent-streaming'); this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); this.streamingCompleted = true; @@ -1352,6 +1356,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): this.wrapper.classList.remove('chat-thinking-streaming'); } this.domNode.classList.remove('chat-thinking-active'); + this.domNode.classList.remove('chat-thinking-persistent-streaming'); this.streamingCompleted = true; if (this._collapseButton) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css index 150b0c638863d..47031cccd9734 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css @@ -375,6 +375,10 @@ display: none; } + &.chat-thinking-persistent-streaming { + margin-bottom: 0; + } + > .monaco-scrollable-element > .shadow { display: none; } From a2ce40b82dc8b679cd1e98f12cd86d16ac2580e6 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:35:50 -0700 Subject: [PATCH 2/6] Use observables for the dropdowns in Claude (#311277) * Use observables for the dropdowns in Claude This makes it wayyyy easier to test as we can trigger the pipeline and see if the expected groups get created. Co-authored-by: Copilot * feedback --------- Co-authored-by: Copilot --- .../node/sessionParser/claudeSessionSchema.ts | 2 + .../node/sessionParser/sdkSessionAdapter.ts | 3 +- .../claudeChatSessionContentProvider.ts | 308 +++++++++++++----- .../vscode-node/claudeSessionOptionBuilder.ts | 60 ++-- .../claudeChatSessionContentProvider.spec.ts | 234 ++++++++++++- .../test/claudeSessionOptionBuilder.spec.ts | 35 +- 6 files changed, 505 insertions(+), 137 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeSessionSchema.ts b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeSessionSchema.ts index 8035e48cbfcc3..7d3e1c3cd0beb 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeSessionSchema.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeSessionSchema.ts @@ -517,6 +517,8 @@ export interface IClaudeCodeSessionInfo { readonly folderName?: string; /** Current working directory of the session */ readonly cwd?: string; + /** Git branch of the session */ + readonly gitBranch?: string; } // #endregion diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/sdkSessionAdapter.ts b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/sdkSessionAdapter.ts index f074a576e8a92..725d562fbc45c 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/sdkSessionAdapter.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/sdkSessionAdapter.ts @@ -89,7 +89,8 @@ export function sdkSessionInfoToSessionInfo( created: info.createdAt ?? info.lastModified, lastRequestEnded: info.lastModified, folderName, - cwd: info.cwd + cwd: info.cwd, + gitBranch: info.gitBranch, }; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index 5293b7a177fb2..fe1430a79a711 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -5,13 +5,17 @@ import * as vscode from 'vscode'; import { ChatExtendedRequestHandler } from 'vscode'; +import { PermissionMode } from '@anthropic-ai/claude-agent-sdk'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { INativeEnvService } from '../../../platform/env/common/envService'; import { IGitService } from '../../../platform/git/common/gitService'; import { ILogService } from '../../../platform/log/common/logService'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; -import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { Emitter, Event } from '../../../util/vs/base/common/event'; +import { Disposable, DisposableStore } from '../../../util/vs/base/common/lifecycle'; +import { autorun, derived, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../util/vs/base/common/observable'; +import { basename } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; import { generateUuid } from '../../../util/vs/base/common/uuid'; import { ClaudeFolderInfo } from '../claude/common/claudeFolderInfo'; @@ -25,7 +29,8 @@ import { IClaudeCodeSessionInfo } from '../claude/node/sessionParser/claudeSessi import { IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCommandService'; import { IChatFolderMruService } from '../common/folderRepositoryManager'; import { buildChatHistory } from './chatHistoryBuilder'; -import { ClaudeSessionOptionBuilder, FOLDER_OPTION_ID, isPermissionMode, PERMISSION_MODE_OPTION_ID } from './claudeSessionOptionBuilder'; +import { ClaudeSessionOptionBuilder, buildPermissionModeItems, FOLDER_OPTION_ID, isPermissionMode, PERMISSION_MODE_OPTION_ID } from './claudeSessionOptionBuilder'; +import { toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder'; // Import the tool permission handlers import '../claude/vscode-node/toolPermissionHandlers/index'; @@ -33,6 +38,14 @@ import '../claude/vscode-node/toolPermissionHandlers/index'; // Import the MCP server contributors to trigger self-registration import '../claude/vscode-node/mcpServers/index'; +interface InputStateReactivePipeline { + readonly permissionMode: ISettableObservable; + readonly folderUri: ISettableObservable; + readonly folderItems: ISettableObservable; + readonly isSessionStarted: ISettableObservable; + readonly store: DisposableStore; +} + export class ClaudeChatSessionContentProvider extends Disposable implements vscode.ChatSessionContentProvider { private readonly _controller: ClaudeChatSessionItemController; @@ -87,17 +100,7 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco // Lock the folder group when starting a new session (permission mode stays editable) if (isNewSession) { - const state = chatSessionContext.inputState; - state.groups = state.groups.map(group => { - if (group.id !== FOLDER_OPTION_ID) { - return group; - } - return { - ...group, - items: group.items.map(item => ({ ...item, locked: true })), - selected: group.selected ? { ...group.selected, locked: true } : undefined, - }; - }); + this._controller.markSessionStarted(chatSessionContext.inputState); } const modelId = parseClaudeModelId(request.model.id); @@ -176,10 +179,28 @@ export class ClaudeChatSessionItemController extends Disposable { private readonly _inProgressItems = new Map(); private _showBadge: boolean; + // #region Shared Observable State + + /** Whether the "bypass permissions" config is enabled — controls permission mode items. */ + private readonly _bypassPermissionsEnabled: IObservable; + + /** Current workspace folders — controls folder group items and visibility. */ + private readonly _workspaceFolders: IObservable; + + /** Disposes per-state autoruns when the state object is garbage collected. */ + private readonly _stateAutorunRegistry = new FinalizationRegistry( + store => store.dispose() + ); + + /** Maps input state objects to their reactive pipelines for external updates. */ + private readonly _statePipelines = new WeakMap(); + + // #endregion + constructor( @IClaudeCodeSessionService private readonly _claudeCodeSessionService: IClaudeCodeSessionService, @IClaudeSessionStateService private readonly _sessionStateService: IClaudeSessionStateService, - @IConfigurationService private readonly _configurationService: IConfigurationService, + @IConfigurationService _configurationService: IConfigurationService, @IChatFolderMruService folderMruService: IChatFolderMruService, @IWorkspaceService private readonly _workspaceService: IWorkspaceService, @INativeEnvService private readonly _envService: INativeEnvService, @@ -189,6 +210,24 @@ export class ClaudeChatSessionItemController extends Disposable { ) { super(); this._optionBuilder = new ClaudeSessionOptionBuilder(_configurationService, folderMruService, _workspaceService); + + this._bypassPermissionsEnabled = observableFromEvent( + this, + Event.filter(_configurationService.onDidChangeConfiguration, + e => e.affectsConfiguration(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions.fullyQualifiedId)), + () => _configurationService.getConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions) as boolean, + ); + + // Bridge vscode.Event → internal Event for workspace folder changes + const workspaceFoldersEmitter = this._register(new Emitter()); + const workspaceFoldersSubscription = _workspaceService.onDidChangeWorkspaceFolders(() => workspaceFoldersEmitter.fire()); + this._register({ dispose: () => workspaceFoldersSubscription.dispose() }); + this._workspaceFolders = observableFromEvent( + this, + workspaceFoldersEmitter.event, + () => _workspaceService.getWorkspaceFolders(), + ); + this._registerCommands(); this._controller = this._register(vscode.chat.createChatSessionItemController( ClaudeSessionUri.scheme, @@ -277,79 +316,206 @@ export class ClaudeChatSessionItemController extends Disposable { // #region Input State - private _setupInputState(): void { - const trackedStates: { ref: WeakRef }[] = []; + /** + * Creates a reactive pipeline for a single input state. + * + * Per-state observables (`permissionMode`, `folderUri`, `isSessionStarted`) are + * combined with shared observables (`_bypassPermissionsEnabled`, `_workspaceFolders`) + * into derived group computations. An autorun reads the derived groups and pushes + * the result to `state.groups`, which is the "UI". + * + * The `state` is only held weakly by the autoruns so it can be garbage-collected + * while the shared observables still reference the pipeline's observers. When the + * state is collected, the finalization registry disposes the store and unsubscribes. + * + * Returns the per-state observables so callers can drive external updates, plus a + * `DisposableStore` that owns the autorun lifecycle. + */ + private _createInputStateReactivePipeline( + state: vscode.ChatSessionInputState, + ): InputStateReactivePipeline { + const store = new DisposableStore(); + + // Seed values are computed up front so that the first autorun pass + // observes fully-seeded observables and does not clobber `initialGroups`. + const seed = this._computeSeedValues(state.groups); + + const permissionMode = observableValue(this, seed.permissionMode); + const folderUri = observableValue(this, seed.folderUri); + const folderItems = observableValue(this, seed.folderItems); + const isSessionStarted = observableValue(this, seed.isSessionStarted); + + // When workspace folders change, update folder items reactively. + // Falls back to the async MRU list when the workspace becomes empty, + // matching the old imperative `buildNewFolderGroup` behavior. + store.add(autorun(reader => { + /** @description syncWorkspaceFolderItems */ + const folders = this._workspaceFolders.read(reader); + if (folders.length !== 0) { + folderItems.set( + folders.map(f => toWorkspaceFolderOptionItem(f, this._workspaceService.getWorkspaceFolderName(f) || basename(f))), + undefined, + ); + } else { + this._optionBuilder.getFolderOptionItems() + .then(items => folderItems.set(items, undefined)) + .catch(e => this._logService.error(e)); + } + })); - const sweepStaleEntries = () => { - for (let i = trackedStates.length - 1; i >= 0; i--) { - if (!trackedStates[i].ref.deref()) { - trackedStates.splice(i, 1); - } + const permissionModeGroup = derived(reader => { + /** @description permissionModeGroup */ + const bypassEnabled = this._bypassPermissionsEnabled.read(reader); + const selectedMode = permissionMode.read(reader); + const group = buildPermissionModeItems(bypassEnabled); + const selectedItem = group.items.find(i => i.id === selectedMode) ?? group.items[0]; + return { ...group, selected: selectedItem }; + }); + + const folderGroup = derived(reader => { + /** @description folderGroup */ + const items = folderItems.read(reader); + const folders = this._workspaceFolders.read(reader); + // Hide folder group when there's exactly one workspace folder (implicit) + if (folders.length === 1) { + return undefined; } - }; + const selectedFolder = folderUri.read(reader); + const locked = isSessionStarted.read(reader); + const lockedItems = locked ? items.map(i => ({ ...i, locked: true })) : items; + const selectedItem = selectedFolder + ? lockedItems.find(i => i.id === selectedFolder.fsPath) + : lockedItems[0]; + return { + id: FOLDER_OPTION_ID, + name: vscode.l10n.t('Folder'), + description: vscode.l10n.t('Pick Folder'), + items: lockedItems, + selected: selectedItem ? (locked ? { ...selectedItem, locked: true } : selectedItem) : undefined, + }; + }); + + const allGroups = derived(reader => { + /** @description allGroups */ + const groups: vscode.ChatSessionProviderOptionGroup[] = []; + const folder = folderGroup.read(reader); + if (folder) { + groups.push(folder); + } + groups.push(permissionModeGroup.read(reader)); + return groups; + }); + + // Hold `state` via a WeakRef so the autorun's closure does not retain it. + // Shared observables (`_workspaceFolders`, `_bypassPermissionsEnabled`) hold + // strong references to autoruns; without the WeakRef, `state` would transitively + // stay reachable forever and `_stateAutorunRegistry` could never fire. + const stateRef = new WeakRef(state); + store.add(autorun(reader => { + /** @description syncInputStateGroups */ + const groups = allGroups.read(reader); + const currentState = stateRef.deref(); + if (currentState) { + currentState.groups = groups; + } + })); + return { permissionMode, folderUri, folderItems, isSessionStarted, store }; + } + + private _setupInputState(): void { this._controller.getChatSessionInputState = async (sessionResource, context, token) => { if (context.previousInputState) { const state = this._controller.createChatSessionInputState([...context.previousInputState.groups]); - trackedStates.push({ ref: new WeakRef(state) }); + const pipeline = this._createInputStateReactivePipeline(state); + this._statePipelines.set(state, pipeline); + this._stateAutorunRegistry.register(state, pipeline.store); return state; } const isExistingSession = sessionResource && await this._claudeCodeSessionService.getSession(sessionResource, token) !== undefined; - - const groups = isExistingSession + const initialGroups = isExistingSession ? await this._buildExistingSessionGroups(sessionResource) : await this._optionBuilder.buildNewSessionGroups(); - const state = this._controller.createChatSessionInputState(groups); - trackedStates.push({ ref: new WeakRef(state) }); - return state; - }; + const state = this._controller.createChatSessionInputState(initialGroups); + const pipeline = this._createInputStateReactivePipeline(state); - // Rebuild active input states when external conditions change - const refreshActiveInputStates = () => { - sweepStaleEntries(); - for (const entry of trackedStates) { - const state = entry.ref.deref(); - if (state) { - this._rebuildInputState(state).catch(e => this._logService.error(e)); - } + if (isExistingSession) { + pipeline.isSessionStarted.set(true, undefined); } - }; - // Config change (bypass permissions toggle) → may add/remove permission items - this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions.fullyQualifiedId)) { - refreshActiveInputStates(); + // React to external permission mode changes for this session + if (sessionResource) { + const sessionId = ClaudeSessionUri.getSessionId(sessionResource); + const externalPermissionMode = observableFromEvent( + this, + Event.filter(this._sessionStateService.onDidChangeSessionState, + e => e.sessionId === sessionId && e.permissionMode !== undefined), + () => this._sessionStateService.getPermissionModeForSession(sessionId), + ); + pipeline.store.add(autorun(reader => { + /** @description syncExternalPermissionMode */ + pipeline.permissionMode.set(externalPermissionMode.read(reader), undefined); + })); } - })); - // Workspace folder changes → may add/remove folder group - this._register(this._workspaceService.onDidChangeWorkspaceFolders(() => { - refreshActiveInputStates(); - })); + this._statePipelines.set(state, pipeline); + this._stateAutorunRegistry.register(state, pipeline.store); + return state; + }; + } - // Session state service changes (e.g., permission mode changed externally) - this._register(this._sessionStateService.onDidChangeSessionState(e => { - if (e.permissionMode === undefined) { - return; + /** + * Extracts seed values for the per-state observables from the input groups. + * Pure and synchronous — runs before any autoruns are attached so the first + * autorun pass observes fully-seeded values and does not overwrite the + * carefully-constructed initial groups. + * + * Also recovers the `isSessionStarted` signal from `locked` items — required to + * preserve lock state when restoring a previously-started session. + */ + private _computeSeedValues(groups: readonly vscode.ChatSessionProviderOptionGroup[]): { + readonly permissionMode: PermissionMode; + readonly folderUri: URI | undefined; + readonly folderItems: readonly vscode.ChatSessionProviderOptionItem[]; + readonly isSessionStarted: boolean; + } { + let permissionMode: PermissionMode = this._optionBuilder.lastUsedPermissionMode; + const permissionGroup = groups.find(g => g.id === PERMISSION_MODE_OPTION_ID); + if (permissionGroup?.selected && isPermissionMode(permissionGroup.selected.id)) { + permissionMode = permissionGroup.selected.id; + } + + let folderUri: URI | undefined; + let folderItems: readonly vscode.ChatSessionProviderOptionItem[] = []; + let isSessionStarted = false; + const folderGroup = groups.find(g => g.id === FOLDER_OPTION_ID); + if (folderGroup) { + if (folderGroup.items.length > 0) { + folderItems = folderGroup.items; } - for (const entry of trackedStates) { - const state = entry.ref.deref(); - if (state?.sessionResource) { - const stateSessionId = ClaudeSessionUri.getSessionId(state.sessionResource); - if (stateSessionId === e.sessionId) { - const permissionGroup = this._optionBuilder.buildPermissionModeGroup(); - const selectedItem = permissionGroup.items.find(i => i.id === e.permissionMode); - if (selectedItem) { - const updatedGroup = { ...permissionGroup, selected: selectedItem }; - state.groups = state.groups.map(g => - g.id === PERMISSION_MODE_OPTION_ID ? updatedGroup : g - ); - } - } - } + if (folderGroup.selected) { + folderUri = URI.file(folderGroup.selected.id); } - })); + // Restore the "started" signal: if any items (or the selected item) carry + // `locked: true`, the session was previously started and must stay locked. + if (folderGroup.selected?.locked || folderGroup.items.some(i => i.locked)) { + isSessionStarted = true; + } + } + + return { permissionMode, folderUri, folderItems, isSessionStarted }; + } + + /** + * Marks the input state as "started", which locks the folder group. + * Called by the content provider when a new session begins. + */ + markSessionStarted(inputState: vscode.ChatSessionInputState): void { + const pipeline = this._statePipelines.get(inputState); + if (pipeline) { + pipeline.isSessionStarted.set(true, undefined); + } } private async _buildExistingSessionGroups(sessionResource: vscode.Uri): Promise { @@ -374,14 +540,6 @@ export class ClaudeChatSessionItemController extends Disposable { return this._optionBuilder.buildExistingSessionGroups(permissionMode, folderUri); } - private async _rebuildInputState(state: vscode.ChatSessionInputState): Promise { - if (state.sessionResource) { - state.groups = await this._buildExistingSessionGroups(state.sessionResource); - } else { - state.groups = await this._optionBuilder.buildNewSessionGroups(state); - } - } - // #endregion // #region Folder Resolution diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts index 4f579a5937234..87b5d2d250c43 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts @@ -42,18 +42,16 @@ export class ClaudeSessionOptionBuilder { private readonly _workspaceService: IWorkspaceService, ) { } - async buildNewSessionGroups(previousInputState?: vscode.ChatSessionInputState): Promise { + async buildNewSessionGroups(): Promise { const groups: vscode.ChatSessionProviderOptionGroup[] = []; - const folderGroup = await this.buildNewFolderGroup(previousInputState); + const folderGroup = await this.buildNewFolderGroup(); if (folderGroup) { groups.push(folderGroup); } const permissionGroup = this.buildPermissionModeGroup(); - const previousPermission = previousInputState ? getSelectedOption(previousInputState.groups, PERMISSION_MODE_OPTION_ID) : undefined; - const selectedPermissionId = previousPermission?.id ?? this._lastUsedPermissionMode; - const selectedPermission = permissionGroup.items.find(i => i.id === selectedPermissionId); + const selectedPermission = permissionGroup.items.find(i => i.id === this._lastUsedPermissionMode); groups.push({ ...permissionGroup, selected: selectedPermission ?? permissionGroup.items[0], @@ -80,40 +78,23 @@ export class ClaudeSessionOptionBuilder { } buildPermissionModeGroup(): vscode.ChatSessionProviderOptionGroup { - const items: vscode.ChatSessionProviderOptionItem[] = [ - { id: 'default', name: l10n.t('Ask before edits') }, - { id: 'acceptEdits', name: l10n.t('Edit automatically') }, - { id: 'plan', name: l10n.t('Plan mode') }, - ]; - - if (this._configurationService.getConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions)) { - items.push({ id: 'bypassPermissions', name: l10n.t('Bypass all permissions') }); - } - - return { - id: PERMISSION_MODE_OPTION_ID, - name: l10n.t('Permission Mode'), - description: l10n.t('Pick Permission Mode'), - items, - }; + const bypassEnabled = this._configurationService.getConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions); + return buildPermissionModeItems(bypassEnabled); } - async buildNewFolderGroup(previousInputState: vscode.ChatSessionInputState | undefined): Promise { + async buildNewFolderGroup(): Promise { const workspaceFolders = this._workspaceService.getWorkspaceFolders(); if (workspaceFolders.length === 1) { return undefined; } const folderItems = await this.getFolderOptionItems(); - const previousFolder = previousInputState ? getSelectedOption(previousInputState.groups, FOLDER_OPTION_ID) : undefined; - const defaultFolderId = previousFolder?.id ?? folderItems[0]?.id; - const selectedItem = defaultFolderId ? folderItems.find(i => i.id === defaultFolderId) : undefined; return { id: FOLDER_OPTION_ID, name: l10n.t('Folder'), description: l10n.t('Pick Folder'), items: folderItems, - selected: selectedItem ?? folderItems[0], + selected: folderItems[0], }; } @@ -176,3 +157,30 @@ export class ClaudeSessionOptionBuilder { return { permissionMode, folderUri }; } } + +// #region Pure group-building functions (observable-friendly) + +/** + * Build the permission mode option group from explicit inputs. + * Pure and synchronous — suitable for use in `derived` computations. + */ +export function buildPermissionModeItems(bypassEnabled: boolean): vscode.ChatSessionProviderOptionGroup { + const items: vscode.ChatSessionProviderOptionItem[] = [ + { id: 'default', name: l10n.t('Ask before edits') }, + { id: 'acceptEdits', name: l10n.t('Edit automatically') }, + { id: 'plan', name: l10n.t('Plan mode') }, + ]; + + if (bypassEnabled) { + items.push({ id: 'bypassPermissions', name: l10n.t('Bypass all permissions') }); + } + + return { + id: PERMISSION_MODE_OPTION_ID, + name: l10n.t('Permission Mode'), + description: l10n.t('Pick Permission Mode'), + items, + }; +} + +// #endregion diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts index 180746f260cb7..6b24f11ea52be 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import type * as vscode from 'vscode'; // eslint-disable-next-line no-duplicate-imports import * as vscodeShim from 'vscode'; +import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { IGitService, RepoContext } from '../../../../platform/git/common/gitService'; import { MockGitService } from '../../../../platform/ignore/node/test/mockGitService'; import { ITestingServicesAccessor } from '../../../../platform/test/node/services'; @@ -202,15 +203,38 @@ function buildInputStateGroups(options?: { permissionMode?: string; folderPath?: return groups; } +/** + * Workspace service whose folder list can be mutated at runtime so tests can + * exercise folder-change events through the observable pipeline. + */ +class MutableWorkspaceService extends TestWorkspaceService { + private _folders: URI[]; + + constructor(folders: URI[]) { + super(folders); + this._folders = [...folders]; + } + + override getWorkspaceFolders(): URI[] { + return this._folders; + } + + setFolders(folders: URI[]): void { + this._folders = [...folders]; + this.didChangeWorkspaceFoldersEmitter.fire({ added: [], removed: [] } as any); + } +} + function createProviderWithServices( store: DisposableStore, workspaceFolders: URI[], mocks: ReturnType, agentManager?: ClaudeAgentManager, + workspaceServiceOverride?: TestWorkspaceService, ): { provider: ClaudeChatSessionContentProvider; accessor: ITestingServicesAccessor } { const serviceCollection = store.add(createExtensionUnitTestingServices(store)); - const workspaceService = new TestWorkspaceService(workspaceFolders); + const workspaceService = workspaceServiceOverride ?? new TestWorkspaceService(workspaceFolders); serviceCollection.set(IWorkspaceService, workspaceService); serviceCollection.set(IGitService, new MockGitService()); @@ -965,6 +989,190 @@ describe('ChatSessionContentProvider', () => { }); // #endregion + + // #region Observable pipeline reactivity + + /** + * These tests drive the input-state observable pipeline end-to-end via the + * external signals it observes (config change, workspace folder change, + * session-state change, session start) and assert the resulting + * `state.groups` reflect each event. This is the "series of events" testing + * the observable refactor was designed to enable. + */ + describe('observable pipeline reactivity', () => { + const folderA = URI.file('/project-a'); + const folderB = URI.file('/project-b'); + + async function flushMicrotasks(): Promise { + // Autoruns that schedule async work (e.g. MRU fetch when workspace goes empty) + // settle on the microtask queue. Two ticks covers chained thenables. + await Promise.resolve(); + await Promise.resolve(); + } + + it('toggling bypass-permissions config adds/removes the bypass item reactively', async () => { + const mocks = createDefaultMocks(); + const { accessor: localAccessor } = createProviderWithServices(store, [folderA, folderB], mocks); + const configService = localAccessor.get(IConfigurationService); + + const state = await getInputState(); + let permissionGroup = getGroup(state, 'permissionMode')!; + expect(permissionGroup.items.map(i => i.id)).not.toContain('bypassPermissions'); + + await configService.setConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions, true); + permissionGroup = getGroup(state, 'permissionMode')!; + expect(permissionGroup.items.map(i => i.id)).toContain('bypassPermissions'); + + await configService.setConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions, false); + permissionGroup = getGroup(state, 'permissionMode')!; + expect(permissionGroup.items.map(i => i.id)).not.toContain('bypassPermissions'); + }); + + it('workspace folder changes reshape the folder group', async () => { + const mocks = createDefaultMocks(); + const mutableWs = new MutableWorkspaceService([folderA, folderB]); + createProviderWithServices(store, [], mocks, undefined, mutableWs); + + const state = await getInputState(); + let folderGroup = getGroup(state, 'folder'); + expect(folderGroup).toBeDefined(); + expect(folderGroup!.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]); + + // Add a third folder + const folderC = URI.file('/project-c'); + mutableWs.setFolders([folderA, folderB, folderC]); + folderGroup = getGroup(state, 'folder'); + expect(folderGroup!.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath, folderC.fsPath]); + + // Transition to a single folder → group hides + mutableWs.setFolders([folderA]); + folderGroup = getGroup(state, 'folder'); + expect(folderGroup).toBeUndefined(); + + // Back to multi-root + mutableWs.setFolders([folderA, folderB]); + folderGroup = getGroup(state, 'folder'); + expect(folderGroup!.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]); + }); + + it('emptying the workspace falls back to MRU items', async () => { + const mocks = createDefaultMocks(); + const mutableWs = new MutableWorkspaceService([folderA, folderB]); + const mruFolder = URI.file('/recent/project'); + mocks.mockFolderMruService.setMRUEntries([ + { folder: mruFolder, repository: undefined, lastAccessed: Date.now() }, + ]); + createProviderWithServices(store, [], mocks, undefined, mutableWs); + + const state = await getInputState(); + mutableWs.setFolders([]); + await flushMicrotasks(); + + const folderGroup = getGroup(state, 'folder'); + expect(folderGroup).toBeDefined(); + expect(folderGroup!.items.map(i => i.id)).toEqual([mruFolder.fsPath]); + }); + + it('external session-state permission change syncs into the input state', async () => { + const mocks = createDefaultMocks(); + const { accessor: localAccessor } = createProviderWithServices(store, [workspaceFolderUri], mocks); + const sessionStateService = localAccessor.get(IClaudeSessionStateService); + + // Mark as existing so the pipeline wires up the external permission autorun + const existingSession = { id: 'external-session', messages: [], subagents: [] }; + vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue(existingSession as any); + + const sessionUri = createClaudeSessionUri('external-session'); + const state = await getInputState(sessionUri); + expect(getGroup(state, 'permissionMode')!.selected?.id).not.toBe('plan'); + + sessionStateService.setPermissionModeForSession('external-session', 'plan'); + expect(getGroup(state, 'permissionMode')!.selected?.id).toBe('plan'); + + sessionStateService.setPermissionModeForSession('external-session', 'default'); + expect(getGroup(state, 'permissionMode')!.selected?.id).toBe('default'); + }); + + it('markSessionStarted locks the folder group mid-session', async () => { + const mocks = createDefaultMocks(); + createProviderWithServices(store, [folderA, folderB], mocks); + + const state = await getInputState(); + let folderGroup = getGroup(state, 'folder')!; + expect(folderGroup.items.every(i => !i.locked)).toBe(true); + expect(folderGroup.selected?.locked).toBeUndefined(); + + // Simulate a new session starting by invoking the handler (which calls markSessionStarted) + // The handler is owned by the content provider — we go through it via createHandler. + // Easier: reach through via the exported accessor pattern — call markSessionStarted through the controller. + // The content provider does not export the controller, but the handler path covers it. + vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue(undefined); + seedSessionItem('new-session'); + + const { provider: handlerProvider } = createProviderWithServices(store, [folderA, folderB], mocks); + const handler = handlerProvider.createHandler(); + // The state we want to observe must be the one passed into the handler + const newState = await getInputState(); + const context: vscode.ChatContext = { + history: [], + yieldRequested: false, + chatSessionContext: { + isUntitled: false, + chatSessionItem: { + resource: ClaudeSessionUri.forSessionId('new-session'), + label: 'New', + }, + inputState: newState, + }, + } as vscode.ChatContext; + await handler(createTestRequest('hello'), context, new MockChatResponseStream(), CancellationToken.None); + + folderGroup = getGroup(newState, 'folder')!; + expect(folderGroup.items.every(i => i.locked === true)).toBe(true); + expect(folderGroup.selected?.locked).toBe(true); + }); + + it('restoring a locked previousInputState preserves the lock across workspace changes', async () => { + const mocks = createDefaultMocks(); + const mutableWs = new MutableWorkspaceService([folderA, folderB]); + createProviderWithServices(store, [], mocks, undefined, mutableWs); + + // First state — mark it as started to get locked items + const initialState = await getInputState(); + const initialGroup = getGroup(initialState, 'folder')!; + // Synthesize a locked previousInputState (matching what a started session looks like) + const lockedGroups: vscode.ChatSessionProviderOptionGroup[] = initialState.groups.map(g => + g.id === 'folder' + ? { + ...g, + items: g.items.map(i => ({ ...i, locked: true })), + selected: g.selected ? { ...g.selected, locked: true } : undefined, + } + : g + ); + const lockedPrevious: vscode.ChatSessionInputState = { + groups: lockedGroups, + sessionResource: undefined, + onDidChange: Event.None, + }; + // sanity check + expect(initialGroup.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]); + + // Restore from the locked previous state + const restoredState = await getInputState(undefined, lockedPrevious); + let restoredGroup = getGroup(restoredState, 'folder')!; + expect(restoredGroup.items.every(i => i.locked === true)).toBe(true); + + // Now workspace folders change — lock must persist + const folderC = URI.file('/project-c'); + mutableWs.setFolders([folderA, folderB, folderC]); + restoredGroup = getGroup(restoredState, 'folder')!; + expect(restoredGroup.items).toHaveLength(3); + expect(restoredGroup.items.every(i => i.locked === true)).toBe(true); + }); + }); + + // #endregion }); // #region FakeGitService @@ -1006,6 +1214,7 @@ describe('ClaudeChatSessionItemController', () => { let mockSessionService: IClaudeCodeSessionService; let mockSdkService: IClaudeCodeSdkService; let controller: ClaudeChatSessionItemController; + let lastControllerAccessor: ITestingServicesAccessor; function getItem(sessionId: string): vscode.ChatSessionItem | undefined { return lastCreatedItemsMap.get(ClaudeSessionUri.forSessionId(sessionId).toString()); @@ -1031,6 +1240,7 @@ describe('ClaudeChatSessionItemController', () => { }; serviceCollection.define(IClaudeCodeSdkService, mockSdkService); const accessor = serviceCollection.createTestingAccessor(); + lastControllerAccessor = accessor; const ctrl = accessor.get(IInstantiationService).createInstance(ClaudeChatSessionItemController); store.add(ctrl); return ctrl; @@ -1504,12 +1714,24 @@ describe('ClaudeChatSessionItemController', () => { label: 'Original', }); - const result = await lastForkHandler!(sessionResource, undefined, CancellationToken.None); + // Seed the parent session with non-default state + const sessionStateService = lastControllerAccessor.get(IClaudeSessionStateService); + sessionStateService.setPermissionModeForSession('sess-1', 'plan'); + sessionStateService.setFolderInfoForSession('sess-1', { + cwd: '/custom/folder', + additionalDirectories: ['/extra'], + }); - // The forked item should be properly structured - expect(result.resource.toString()).toContain('forked-session-id'); - expect(result.iconPath).toBeDefined(); - expect(result.timing).toBeDefined(); + const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession'); + const setFolderInfoSpy = vi.spyOn(sessionStateService, 'setFolderInfoForSession'); + + await lastForkHandler!(sessionResource, undefined, CancellationToken.None); + + expect(setPermissionSpy).toHaveBeenCalledWith('forked-session-id', 'plan'); + expect(setFolderInfoSpy).toHaveBeenCalledWith('forked-session-id', { + cwd: '/custom/folder', + additionalDirectories: ['/extra'], + }); }); it('forks at the message before the specified request', async () => { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeSessionOptionBuilder.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeSessionOptionBuilder.spec.ts index 8621db5e8ab4c..a163f78a0e5bc 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeSessionOptionBuilder.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeSessionOptionBuilder.spec.ts @@ -8,7 +8,6 @@ import type * as vscode from 'vscode'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspaceService'; import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; -import { Event } from '../../../../util/vs/base/common/event'; import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; import { URI } from '../../../../util/vs/base/common/uri'; import { createExtensionUnitTestingServices } from '../../../test/node/services'; @@ -76,7 +75,7 @@ describe('ClaudeSessionOptionBuilder', () => { it('returns undefined for single-root workspace', async () => { builder = createBuilder([URI.file('/project')]); - const group = await builder.buildNewFolderGroup(undefined); + const group = await builder.buildNewFolderGroup(); expect(group).toBeUndefined(); }); @@ -86,7 +85,7 @@ describe('ClaudeSessionOptionBuilder', () => { const folderB = URI.file('/b'); builder = createBuilder([folderA, folderB]); - const group = await builder.buildNewFolderGroup(undefined); + const group = await builder.buildNewFolderGroup(); expect(group).toBeDefined(); expect(group!.id).toBe('folder'); @@ -101,33 +100,11 @@ describe('ClaudeSessionOptionBuilder', () => { { folder: mruFolder, repository: undefined, lastAccessed: Date.now() }, ]); - const group = await builder.buildNewFolderGroup(undefined); + const group = await builder.buildNewFolderGroup(); expect(group).toBeDefined(); expect(group!.items[0].id).toBe(mruFolder.fsPath); }); - - it('restores previous folder selection', async () => { - const folderA = URI.file('/a'); - const folderB = URI.file('/b'); - builder = createBuilder([folderA, folderB]); - - const previousInputState = { - groups: [{ - id: 'folder', - name: 'Folder', - description: 'Pick Folder', - items: [{ id: folderB.fsPath, name: 'b' }], - selected: { id: folderB.fsPath, name: 'b' }, - }], - sessionResource: undefined, - onDidChange: Event.None, - } as vscode.ChatSessionInputState; - - const group = await builder.buildNewFolderGroup(previousInputState); - - expect(group!.selected?.id).toBe(folderB.fsPath); - }); }); describe('buildExistingFolderGroup', () => { @@ -147,7 +124,7 @@ describe('ClaudeSessionOptionBuilder', () => { it('includes permission mode group with default selection', async () => { builder = createBuilder([URI.file('/project')]); - const groups = await builder.buildNewSessionGroups(undefined); + const groups = await builder.buildNewSessionGroups(); const permGroup = groups.find(g => g.id === 'permissionMode'); expect(permGroup).toBeDefined(); @@ -157,7 +134,7 @@ describe('ClaudeSessionOptionBuilder', () => { it('excludes folder group for single-root workspace', async () => { builder = createBuilder([URI.file('/project')]); - const groups = await builder.buildNewSessionGroups(undefined); + const groups = await builder.buildNewSessionGroups(); expect(groups.find(g => g.id === 'folder')).toBeUndefined(); }); @@ -165,7 +142,7 @@ describe('ClaudeSessionOptionBuilder', () => { it('includes folder group for multi-root workspace', async () => { builder = createBuilder([URI.file('/a'), URI.file('/b')]); - const groups = await builder.buildNewSessionGroups(undefined); + const groups = await builder.buildNewSessionGroups(); expect(groups.find(g => g.id === 'folder')).toBeDefined(); }); From c8249a6ec09b9988153d8cc786524fb088096f17 Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Sun, 19 Apr 2026 17:58:12 -0700 Subject: [PATCH 3/6] instructions: enhance touch & iOS compatibility guidelines for Agents window (#311281) --- .github/instructions/sessions.instructions.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/instructions/sessions.instructions.md b/.github/instructions/sessions.instructions.md index ef9dd0066c708..6dec29dbb1c85 100644 --- a/.github/instructions/sessions.instructions.md +++ b/.github/instructions/sessions.instructions.md @@ -11,3 +11,10 @@ When working on files under `src/vs/sessions/`, use these skills for detailed gu - **`sessions`** skill — covers the full architecture: layering, folder structure, chat widget, menus, contributions, entry points, and development guidelines - **`agent-sessions-layout`** skill — covers the fixed layout structure, grid configuration, part visibility, editor modal, titlebar, sidebar footer, and implementation requirements + +## Touch & iOS Compatibility + +The Agents window can run on touch-capable platforms (notably iOS). Follow these rules for all DOM interaction code: + +- Do not use `EventType.MOUSE_DOWN`, `EventType.MOUSE_UP`, or `EventType.MOUSE_MOVE` with `addDisposableListener` directly — on iOS, these events don't fire because the platform uses pointer events. Use `addDisposableGenericMouseDownListener`, `addDisposableGenericMouseUpListener`, or `addDisposableGenericMouseMoveListener` instead, which automatically select the correct event type per platform. +- Add `touch-action: manipulation` in CSS on custom clickable elements (e.g. picker triggers, title bar pills, or other `
`/`` elements styled as buttons) to eliminate the 300ms tap delay on touch devices. This is not needed for native `