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 `` elements or standard VS Code widgets (quick picks, context menus, action bar items) which already handle touch behavior.
diff --git a/.github/skills/heap-snapshot-analysis/SKILL.md b/.github/skills/heap-snapshot-analysis/SKILL.md
index 41a906c079b6e..2f49c9966fd3d 100644
--- a/.github/skills/heap-snapshot-analysis/SKILL.md
+++ b/.github/skills/heap-snapshot-analysis/SKILL.md
@@ -33,6 +33,8 @@ If the user needs the agent to launch VS Code, drive a scenario, and capture sna
Use the helpers in [parseSnapshot.ts](./helpers/parseSnapshot.ts) to load snapshots. The files are often >500MB and too large for `JSON.parse` as a string — the helpers use Buffer-based extraction. In scratchpad scripts, import helpers from `../helpers/*.ts`.
+For very large snapshots, the helper may still be too eager. Node cannot create a Buffer larger than roughly 2 GiB, so snapshots above that size can fail with `ERR_FS_FILE_TOO_LARGE` even before parsing. In that case, do not try to raise `--max-old-space-size` and retry the same full-file read. Switch to a streaming script.
+
```typescript
import { parseSnapshot, buildGraph } from '../helpers/parseSnapshot.ts';
@@ -40,6 +42,44 @@ const data = parseSnapshot('/path/to/snapshot.heapsnapshot');
const graph = buildGraph(data);
```
+#### Snapshots Larger Than 2 GiB
+
+When a snapshot is too large to load into a single Buffer, write scratchpad scripts that scan and parse only the sections needed for the question. Use [streamSnapshot.mjs](./helpers/streamSnapshot.mjs) for the common streaming primitives instead of copying them between scratch scripts.
+
+Useful tricks:
+
+- Find top-level section offsets first. Scan the file as bytes for markers like `"nodes":`, `"edges":`, `"strings":`, and `"trace_function_infos":`. This lets follow-up scripts jump directly to the large arrays instead of searching the whole file repeatedly.
+- Parse `snapshot.meta` separately from the small header at the start of the file. Use `meta.node_fields`, `meta.node_types`, `meta.edge_fields`, and `meta.edge_types` to avoid hard-coding tuple widths.
+- Stream numeric arrays in chunks. For `nodes` and `edges`, keep a small carryover string between chunks, split on commas, and process complete numeric tokens as they arrive.
+- Avoid materializing the full `strings` table unless the investigation truly needs it. If you only need suspicious names, collect string indexes from matching nodes/edges first, then resolve only those indexes in a second streaming pass.
+- If you do need many strings, store only short previews and category counters. Full source strings, ref-listing strings, and prompt payloads can dominate memory and make the analyzer become the leak.
+- Write intermediate outputs to files in the scratchpad. Large heap analysis is iterative and slow; cached node ids, offsets, and retainer traces save repeated multi-minute passes.
+- Prefer self-size attribution and field-level ownership for huge graphs. Full retained-size walks can wildly overcount shared services, roots, maps, and singleton caches.
+- When quantifying a suspected owner, count obvious owned fields separately: wrapper object, key arrays, array elements, direct strings, and parent strings of sliced/concatenated strings. This often gives a better lower-bound than a single direct string bucket.
+- Be explicit about approximation boundaries. A field-level subtotal usually undercounts listeners/watchers/back-references but avoids the much worse problem of attributing the whole runtime to one object.
+
+Example large-snapshot workflow:
+
+```javascript
+import { findArrayStart, findTokenOffsets, parseMeta, streamNumberTuples } from '../../helpers/streamSnapshot.mjs';
+
+const { size, offsets } = findTokenOffsets(snapshotPath);
+const meta = parseMeta(snapshotPath);
+const nodeFieldCount = meta.node_fields.length;
+const nodesStart = findArrayStart(snapshotPath, offsets.get('"nodes"'));
+
+streamNumberTuples(snapshotPath, nodesStart, offsets.get('"edges"'), nodeFieldCount, (node, nodeIndex) => {
+ // node is reused for speed; copy it before storing.
+});
+```
+
+```bash
+cd .github/skills/heap-snapshot-analysis
+node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/findOffsets.mjs /path/to/Heap.heapsnapshot
+node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/streamAnalyze.mjs /path/to/Heap.heapsnapshot > scratchpad/YYYY-MM-DD-topic/streamAnalyze.out
+node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/traceNodes.mjs /path/to/Heap.heapsnapshot 12345 67890 > scratchpad/YYYY-MM-DD-topic/traceNodes.out
+```
+
### 2. Compare Before/After
Use [compareSnapshots.ts](./helpers/compareSnapshots.ts) to diff two snapshots:
@@ -134,6 +174,7 @@ override dispose() {
### False Retainers to Watch For
+- **DevTools debugger global handles**: If the snapshot was captured after opening DevTools, large source strings, compiled scripts, preview data, inspected objects, or debugger bookkeeping can be retained by paths like `DevTools debugger(internal)` → `synthetic::(Global handles)` → GC roots. Treat these as debugger-induced until proven otherwise. They may not exist in the app before DevTools opens, and they should not be confused with application-owned leaks.
- **`DevToolsLogger._aliveInstances`** (Map): Enabled by `VSCODE_DEV_DEBUG_OBSERVABLES` env var. Retains ALL observed observables. Check if this is active before investigating observable-rooted paths.
- **`GCBasedDisposableTracker` (FinalizationRegistry)**: If `register(target, held, target)` is used (target === unregister token), creates a strong self-reference preventing GC. Currently commented out in production.
- **WeakMap backing arrays**: Show up in retainer paths but don't prevent collection.
diff --git a/.github/skills/heap-snapshot-analysis/helpers/streamSnapshot.mjs b/.github/skills/heap-snapshot-analysis/helpers/streamSnapshot.mjs
new file mode 100644
index 0000000000000..242f9afc5a070
--- /dev/null
+++ b/.github/skills/heap-snapshot-analysis/helpers/streamSnapshot.mjs
@@ -0,0 +1,260 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { closeSync, openSync, readSync, statSync } from 'fs';
+
+export const defaultTopLevelTokens = [
+ '"meta"',
+ '"nodes"',
+ '"edges"',
+ '"trace_function_infos"',
+ '"trace_tree"',
+ '"samples"',
+ '"locations"',
+ '"strings"'
+];
+
+export function formatBytes(bytes) {
+ if (Math.abs(bytes) < 1024) {
+ return `${bytes} B`;
+ }
+ if (Math.abs(bytes) < 1024 * 1024) {
+ return `${(bytes / 1024).toFixed(1)} KB`;
+ }
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+}
+
+export function findTokenOffsets(path, tokens = defaultTopLevelTokens, options = {}) {
+ const stat = statSync(path);
+ const fd = openSync(path, 'r');
+ const chunkSize = options.chunkSize ?? 8 * 1024 * 1024;
+ const overlap = options.overlap ?? 256;
+ const found = new Map();
+ let previous = Buffer.alloc(0);
+ let position = 0;
+
+ try {
+ while (position < stat.size && found.size < tokens.length) {
+ const toRead = Math.min(chunkSize, stat.size - position);
+ const chunk = Buffer.allocUnsafe(toRead);
+ const bytesRead = readSync(fd, chunk, 0, toRead, position);
+ if (bytesRead <= 0) {
+ break;
+ }
+
+ const combined = Buffer.concat([previous, chunk.subarray(0, bytesRead)]);
+
+ for (const token of tokens) {
+ if (found.has(token)) {
+ continue;
+ }
+
+ const index = combined.indexOf(token);
+ if (index !== -1) {
+ found.set(token, position - previous.length + index);
+ }
+ }
+
+ previous = combined.subarray(Math.max(0, combined.length - overlap));
+ position += bytesRead;
+ }
+ } finally {
+ closeSync(fd);
+ }
+
+ return { size: stat.size, offsets: found };
+}
+
+export function readRange(path, start, length) {
+ const fd = openSync(path, 'r');
+ const buffer = Buffer.allocUnsafe(length);
+ let offset = 0;
+
+ try {
+ while (offset < length) {
+ const bytesRead = readSync(fd, buffer, offset, length - offset, start + offset);
+ if (bytesRead === 0) {
+ return buffer.subarray(0, offset);
+ }
+ offset += bytesRead;
+ }
+ return buffer;
+ } finally {
+ closeSync(fd);
+ }
+}
+
+export function parseMeta(path, options = {}) {
+ const maxBytes = options.maxBytes ?? 1024 * 1024;
+ const buffer = readRange(path, 0, maxBytes);
+ const metaPosition = buffer.indexOf(Buffer.from('"meta"'));
+ if (metaPosition === -1) {
+ throw new Error('Unable to find snapshot meta section');
+ }
+
+ const start = buffer.indexOf(Buffer.from('{'), metaPosition);
+ if (start === -1) {
+ throw new Error('Unable to find snapshot meta object start');
+ }
+
+ let depth = 0;
+ for (let i = start; i < buffer.length; i++) {
+ if (buffer[i] === 0x22) {
+ i++;
+ while (i < buffer.length) {
+ if (buffer[i] === 0x5c) {
+ i += 2;
+ continue;
+ }
+ if (buffer[i] === 0x22) {
+ break;
+ }
+ i++;
+ }
+ continue;
+ }
+
+ if (buffer[i] === 0x7b) {
+ depth++;
+ } else if (buffer[i] === 0x7d) {
+ depth--;
+ if (depth === 0) {
+ return JSON.parse(buffer.subarray(start, i + 1).toString('utf8'));
+ }
+ }
+ }
+
+ throw new Error(`Unable to parse snapshot meta within first ${formatBytes(maxBytes)}`);
+}
+
+export function findArrayStart(path, tokenOffset, options = {}) {
+ const windowSize = options.windowSize ?? 4096;
+ const buffer = readRange(path, tokenOffset, windowSize);
+ const bracket = buffer.indexOf(Buffer.from('['));
+ if (bracket === -1) {
+ throw new Error(`Unable to find array start near offset ${tokenOffset}`);
+ }
+ return tokenOffset + bracket + 1;
+}
+
+export function streamNumberArray(path, start, end, onNumber, options = {}) {
+ const fd = openSync(path, 'r');
+ const chunkSize = options.chunkSize ?? 16 * 1024 * 1024;
+ const buffer = Buffer.allocUnsafe(chunkSize);
+ let position = start;
+ let number = 0;
+ let inNumber = false;
+ let numberIndex = 0;
+
+ try {
+ while (position < end) {
+ const toRead = Math.min(chunkSize, end - position);
+ const bytesRead = readSync(fd, buffer, 0, toRead, position);
+ if (bytesRead <= 0) {
+ break;
+ }
+
+ for (let i = 0; i < bytesRead; i++) {
+ const code = buffer[i];
+ if (code >= 0x30 && code <= 0x39) {
+ number = number * 10 + code - 0x30;
+ inNumber = true;
+ } else if (inNumber) {
+ onNumber(number, numberIndex++);
+ number = 0;
+ inNumber = false;
+ if (code === 0x5d) {
+ return numberIndex;
+ }
+ } else if (code === 0x5d) {
+ return numberIndex;
+ }
+ }
+
+ position += bytesRead;
+ }
+
+ if (inNumber) {
+ onNumber(number, numberIndex++);
+ }
+ return numberIndex;
+ } finally {
+ closeSync(fd);
+ }
+}
+
+/**
+ * Streams fixed-size tuples from a number array.
+ *
+ * By default, the same mutable tuple array instance is reused for each callback
+ * invocation to avoid per-tuple allocations. Callers must not retain that array
+ * reference after onTuple returns unless options.copyTuple is enabled.
+ */
+export function streamNumberTuples(path, start, end, tupleSize, onTuple, options = {}) {
+ const tuple = new Array(tupleSize);
+ const copyTuple = options.copyTuple === true;
+ let tupleIndex = 0;
+ let fieldIndex = 0;
+
+ const numberCount = streamNumberArray(path, start, end, value => {
+ tuple[fieldIndex++] = value;
+ if (fieldIndex === tupleSize) {
+ onTuple(copyTuple ? tuple.slice() : tuple, tupleIndex++);
+ fieldIndex = 0;
+ }
+ }, options);
+
+ if (fieldIndex !== 0) {
+ throw new Error(`Number array ended with an incomplete tuple: ${fieldIndex}/${tupleSize}`);
+ }
+
+ return { numberCount, tupleCount: tupleIndex };
+}
+
+export function parseStrings(path, stringsTokenOffset, options = {}) {
+ const normalizedOptions = typeof options === 'number' ? { fileSize: options } : options;
+ const fileSize = normalizedOptions.fileSize ?? statSync(path).size;
+ const length = fileSize - stringsTokenOffset;
+ const maxBytes = normalizedOptions.maxBytes ?? 512 * 1024 * 1024;
+
+ if (length > maxBytes) {
+ throw new Error(`Refusing to parse ${formatBytes(length)} strings section into one Buffer. Pass a larger maxBytes value only if this is intentional.`);
+ }
+
+ const buffer = readRange(path, stringsTokenOffset, length);
+ const start = buffer.indexOf(Buffer.from('['));
+ if (start === -1) {
+ throw new Error(`Unable to find strings array near offset ${stringsTokenOffset}`);
+ }
+
+ let depth = 0;
+ for (let i = start; i < buffer.length; i++) {
+ if (buffer[i] === 0x22) {
+ i++;
+ while (i < buffer.length) {
+ if (buffer[i] === 0x5c) {
+ i += 2;
+ continue;
+ }
+ if (buffer[i] === 0x22) {
+ break;
+ }
+ i++;
+ }
+ continue;
+ }
+
+ if (buffer[i] === 0x5b) {
+ depth++;
+ } else if (buffer[i] === 0x5d) {
+ depth--;
+ if (depth === 0) {
+ return JSON.parse(buffer.subarray(start, i + 1).toString('utf8'));
+ }
+ }
+ }
+
+ throw new Error('Unable to parse strings array');
+}
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/copilotcli/common/copilotCLITools.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts
index dae44d37c503e..8bfcbeaf36769 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts
@@ -1601,6 +1601,17 @@ export async function updateTodoListFromSqlItems(
}, token);
}
+export async function clearTodoList(toolsService: IToolsService,
+ toolInvocationToken: ChatParticipantToolToken,
+ token: CancellationToken): Promise {
+ await toolsService.invokeTool(ToolName.CoreManageTodoList, {
+ input: {
+ operation: 'write',
+ todoList: []
+ } satisfies IManageTodoListToolInputParams,
+ toolInvocationToken,
+ }, token);
+}
interface IManageTodoListToolInputParams {
readonly operation?: 'write' | 'read'; // Optional in write-only mode
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts
index e4c1bd69b269e..96f64b577f37c 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts
@@ -29,7 +29,7 @@ import { IToolsService } from '../../../tools/common/toolsService';
import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';
import { ExternalEditTracker } from '../../common/externalEditTracker';
import { getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../../common/workspaceInfo';
-import { enrichToolInvocationWithSubagentMetadata, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, isTodoRelatedSqlQuery, processToolExecutionComplete, processToolExecutionStart, ToolCall, updateTodoListFromSqlItems } from '../common/copilotCLITools';
+import { enrichToolInvocationWithSubagentMetadata, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, isTodoRelatedSqlQuery, processToolExecutionComplete, processToolExecutionStart, ToolCall, updateTodoListFromSqlItems, clearTodoList } from '../common/copilotCLITools';
import { getCopilotCLISessionDir } from './cliHelpers';
import type { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor';
import { ICopilotCLIImageSupport } from './copilotCLIImageSupport';
@@ -381,6 +381,9 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
const editTracker = new ExternalEditTracker();
let sdkRequestId: string | undefined;
const toolIdEditMap = new Map>();
+ clearTodoList(this._toolsService, request.toolInvocationToken, token).catch(err => {
+ this.logService.error(err, '[CopilotCLISession] Failed to clear todo list at start of session');
+ });
/**
* The sequence of events from the SDK is as follows:
* tool.start -> About to run a terminal command
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts
index 25d0dfdeeae45..010be09de1a06 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts
@@ -397,7 +397,7 @@ describe('CopilotCLISession', () => {
const attachments = [{ type: 'file' as const, path: attachedFilePath, displayName: 'attached-file.ts' }];
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, attachments as any, undefined, authInfo, CancellationToken.None);
expect(result).toEqual({ kind: 'denied-interactively-by-user' });
- expect(toolsService.invokeToolCalls).toHaveLength(1);
+ expect(toolsService.invokeToolCalls).toHaveLength(2);
});
it('auto-approves read permission inside working directory without external handler', async () => {
@@ -487,8 +487,8 @@ describe('CopilotCLISession', () => {
// Path must be absolute within workspace, should auto-approve
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);
expect(result).toEqual({ kind: 'denied-interactively-by-user' });
- expect(toolsService.invokeToolCalls).toHaveLength(1);
- expect(toolsService.invokeToolCalls[0].input).toMatchObject({
+ expect(toolsService.invokeToolCalls).toHaveLength(2);
+ expect(toolsService.invokeToolCalls[1].input).toMatchObject({
title: 'Read file(s)',
message: 'Read file'
});
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();
});
diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts
index e0d4cb214be45..5a161d993cc7d 100644
--- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts
+++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts
@@ -384,10 +384,6 @@ export class CopilotAgent extends Disposable implements IAgent {
async listSessions(): Promise {
this._logService.info('[Copilot] Listing sessions...');
- if (!this._githubToken) {
- this._logService.trace('[Copilot] No auth token; returning no sessions');
- return [];
- }
const client = await this._ensureClient();
const sessions = await client.listSessions();
const projectLimiter = new Limiter(4);
@@ -422,10 +418,6 @@ export class CopilotAgent extends Disposable implements IAgent {
private async _listModels(): Promise {
this._logService.info('[Copilot] Listing models...');
- if (!this._githubToken) {
- this._logService.trace('[Copilot] No auth token; returning no models');
- return [];
- }
const client = await this._ensureClient();
const models = await client.listModels();
const result = models.map(m => ({
diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts
index 06b523a51dec8..d3358dc3b5c19 100644
--- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts
+++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts
@@ -279,11 +279,14 @@ suite('CopilotAgent', () => {
assert.strictEqual(getCopilotWorktreeBranchName('12345678-aaaa-bbbb-cccc-123456789abc', 'a'.repeat(48)).length, 'agents/'.length + 48 + '-12345678'.length);
});
- test('returns empty models and sessions before authentication', async () => {
+ test('returns empty models and throws AuthRequired for sessions before authentication', async () => {
const agent = createTestAgent(disposables);
try {
assert.deepStrictEqual(agent.models.get(), []);
- assert.deepStrictEqual(await agent.listSessions(), []);
+ await assert.rejects(
+ () => agent.listSessions(),
+ (error: Error) => error instanceof ProtocolError && error.code === AHP_AUTH_REQUIRED,
+ );
} finally {
await disposeAgent(agent);
}
diff --git a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts
index df787136ed00b..05215dd4e21cd 100644
--- a/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts
+++ b/src/vs/sessions/contrib/agentHost/browser/localAgentHostSessionsProvider.ts
@@ -5,7 +5,7 @@
import { Codicon } from '../../../../base/common/codicons.js';
import { MarkdownString } from '../../../../base/common/htmlContent.js';
-import { IObservable } from '../../../../base/common/observable.js';
+import { autorun, IObservable } from '../../../../base/common/observable.js';
import { basename } from '../../../../base/common/resources.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { URI } from '../../../../base/common/uri.js';
@@ -90,6 +90,23 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide
this._hasRootStateSnapshot = true;
this._syncSessionTypesFromRootState(rootState, didHydrate);
}));
+
+ // Eagerly populate the session cache once authentication has settled.
+ // Without this, the sidebar would only call `getSessions()` after some
+ // other event (e.g. a `notify/sessionAdded` after the user sends a
+ // message) forced a refresh. We wait for `authenticationPending` to
+ // settle because the underlying agent (e.g. CopilotAgent) throws
+ // `AHP_AUTH_REQUIRED` from `listSessions()` until its auth token is
+ // resolved. The `authenticationPending` observable is sticky (once
+ // it goes false it stays false), so this autorun fires
+ // `_refreshSessions()` at most once for the eager-load case.
+ this._register(autorun(reader => {
+ if (this._agentHostService.authenticationPending.read(reader)) {
+ return;
+ }
+ this._cacheInitialized = true;
+ this._refreshSessions();
+ }));
}
// -- BaseAgentHostSessionsProvider hooks ---------------------------------
diff --git a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts
index 731a2a32e10e1..3d9ab80ad55a7 100644
--- a/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts
+++ b/src/vs/sessions/contrib/agentHost/test/browser/localAgentHostSessionsProvider.test.ts
@@ -439,6 +439,65 @@ suite('LocalAgentHostSessionsProvider', () => {
assert.strictEqual(sessions.length, 2);
}));
+ test('eagerly populates and fires onDidChangeSessions after construction without a getSessions() call', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
+ agentHost.addSession(createSession('eager-1', { summary: 'First' }));
+ agentHost.addSession(createSession('eager-2', { summary: 'Second' }));
+
+ const provider = createProvider(disposables, agentHost);
+ const changes: ISessionChangeEvent[] = [];
+ disposables.add(provider.onDidChangeSessions(e => changes.push(e)));
+
+ // Wait for the eager listSessions() triggered by the constructor.
+ await timeout(0);
+
+ assert.deepStrictEqual({
+ eventCount: changes.length,
+ added: changes[0]?.added.map(s => s.title.get()).sort(),
+ removed: changes[0]?.removed.length,
+ changed: changes[0]?.changed.length,
+ cachedTitles: provider.getSessions().map(s => s.title.get()).sort(),
+ }, {
+ eventCount: 1,
+ added: ['First', 'Second'],
+ removed: 0,
+ changed: 0,
+ cachedTitles: ['First', 'Second'],
+ });
+ }));
+
+ test('defers eager session list fetch until authentication settles', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
+ // Simulate fresh launch: auth is pending and the agent host has no
+ // sessions yet (returns []), then auth completes and the real session
+ // list becomes available.
+ agentHost.setAuthenticationPending(true);
+
+ const provider = createProvider(disposables, agentHost);
+ const changes: ISessionChangeEvent[] = [];
+ disposables.add(provider.onDidChangeSessions(e => changes.push(e)));
+
+ await timeout(0);
+
+ assert.strictEqual(changes.length, 0, 'no event should fire while authentication is pending');
+ assert.strictEqual(provider.getSessions().length, 0, 'no sessions should be cached while authentication is pending');
+
+ // Auth completes; sessions become available on the agent host.
+ agentHost.addSession(createSession('after-auth-1', { summary: 'First' }));
+ agentHost.addSession(createSession('after-auth-2', { summary: 'Second' }));
+ agentHost.setAuthenticationPending(false);
+
+ await timeout(0);
+
+ assert.deepStrictEqual({
+ eventCount: changes.length,
+ added: changes[0]?.added.map(s => s.title.get()).sort(),
+ cachedTitles: provider.getSessions().map(s => s.title.get()).sort(),
+ }, {
+ eventCount: 1,
+ added: ['First', 'Second'],
+ cachedTitles: ['First', 'Second'],
+ });
+ }));
+
test('uses project metadata as workspace group source', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
const projectUri = URI.file('/home/user/vscode');
const workingDirectory = URI.file('/tmp/copilot-worktrees/vscode-feature');
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/chatTodoListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts
index 8f0f2c486bcbc..4badd750fb824 100644
--- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts
+++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts
@@ -287,6 +287,7 @@ export class ChatTodoListWidget extends Disposable {
if (!shouldShow) {
this.domNode.classList.remove('has-todos');
+ this.hideWidget();
return;
}
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;
}