diff --git a/.github/instructions/sessions.instructions.md b/.github/instructions/sessions.instructions.md
index 6dec29dbb1c85..69f15894b95cf 100644
--- a/.github/instructions/sessions.instructions.md
+++ b/.github/instructions/sessions.instructions.md
@@ -17,4 +17,5 @@ When working on files under `src/vs/sessions/`, use these skills for detailed gu
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.
+- For custom clickable elements (e.g. picker triggers, title bar pills, or other `
`/`
` elements styled as buttons) that open pickers or menus on click, listen to **both** `EventType.CLICK` and `TouchEventType.Tap` and call `Gesture.addTarget` on the element. On touch devices, including iOS, VS Code relies on the gesture system to emit `TouchEventType.Tap`, and `EventType.CLICK` alone may not reliably fire there. The base `Button` class already does this correctly, so this rule applies to custom non-`` trigger elements.
- 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/extensions/copilot/package.json b/extensions/copilot/package.json
index 70241a6127f6d..e64decdf0498e 100644
--- a/extensions/copilot/package.json
+++ b/extensions/copilot/package.json
@@ -2890,6 +2890,34 @@
"enablement": "!chatSessionRequestInProgress",
"icon": "$(repo)",
"category": "GitHub Copilot"
+ },
+ {
+ "command": "github.copilot.claude.sessions.commit",
+ "title": "%github.copilot.command.claude.sessions.commit%",
+ "enablement": "!chatSessionRequestInProgress",
+ "icon": "$(git-commit)",
+ "category": "GitHub Copilot"
+ },
+ {
+ "command": "github.copilot.claude.sessions.commitAndSync",
+ "title": "%github.copilot.command.claude.sessions.commitAndSync%",
+ "enablement": "!chatSessionRequestInProgress",
+ "icon": "$(sync)",
+ "category": "GitHub Copilot"
+ },
+ {
+ "command": "github.copilot.claude.sessions.sync",
+ "title": "%github.copilot.command.claude.sessions.sync%",
+ "enablement": "!chatSessionRequestInProgress",
+ "icon": "$(sync)",
+ "category": "GitHub Copilot"
+ },
+ {
+ "command": "github.copilot.claude.sessions.initializeRepository",
+ "title": "%github.copilot.command.claude.sessions.initializeRepository%",
+ "enablement": "!chatSessionRequestInProgress",
+ "icon": "$(repo)",
+ "category": "GitHub Copilot"
}
],
"configuration": [
@@ -4657,7 +4685,7 @@
},
"github.copilot.chat.cli.sessionController.enabled": {
"type": "boolean",
- "default": true,
+ "default": false,
"markdownDescription": "%github.copilot.config.cli.sessionController.enabled%",
"tags": [
"advanced"
@@ -5014,6 +5042,26 @@
"command": "github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR",
"when": "chatSessionType == copilotcli && isSessionsWindow && sessions.isolationMode == worktree && sessions.hasGitRepository && sessions.hasGitHubRemote && sessions.hasPullRequest && sessions.hasOpenPullRequest",
"group": "pull_request@1"
+ },
+ {
+ "command": "github.copilot.claude.sessions.initializeRepository",
+ "when": "chatSessionType == claude-code && isSessionsWindow && !sessions.hasGitRepository",
+ "group": "init@1"
+ },
+ {
+ "command": "github.copilot.claude.sessions.commit",
+ "when": "chatSessionType == claude-code && isSessionsWindow && sessions.hasGitRepository && sessions.hasUncommittedChanges",
+ "group": "commit@1"
+ },
+ {
+ "command": "github.copilot.claude.sessions.commitAndSync",
+ "when": "chatSessionType == claude-code && isSessionsWindow && sessions.hasGitRepository && sessions.hasUncommittedChanges && sessions.hasUpstream",
+ "group": "commit@2"
+ },
+ {
+ "command": "github.copilot.claude.sessions.sync",
+ "when": "chatSessionType == claude-code && isSessionsWindow && sessions.hasGitRepository && !sessions.hasUncommittedChanges && sessions.hasUpstream",
+ "group": "sync@1"
}
],
"chat/contextUsage/actions": [
@@ -5373,6 +5421,22 @@
{
"command": "github.copilot.sessions.initializeRepository",
"when": "false"
+ },
+ {
+ "command": "github.copilot.claude.sessions.commit",
+ "when": "false"
+ },
+ {
+ "command": "github.copilot.claude.sessions.commitAndSync",
+ "when": "false"
+ },
+ {
+ "command": "github.copilot.claude.sessions.sync",
+ "when": "false"
+ },
+ {
+ "command": "github.copilot.claude.sessions.initializeRepository",
+ "when": "false"
}
],
"view/title": [
diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json
index 0eeded7c48f73..122540aab726f 100644
--- a/extensions/copilot/package.nls.json
+++ b/extensions/copilot/package.nls.json
@@ -448,6 +448,10 @@
"github.copilot.command.cli.fleet.description": "Enable fleet mode for parallel subagent execution",
"github.copilot.command.cli.remote.description": "Enable remote control for this session",
"github.copilot.command.claude.sessions.rename": "Rename...",
+ "github.copilot.command.claude.sessions.commit": "Commit",
+ "github.copilot.command.claude.sessions.commitAndSync": "Commit and Sync",
+ "github.copilot.command.claude.sessions.sync": "Sync Changes",
+ "github.copilot.command.claude.sessions.initializeRepository": "Initialize Repository",
"github.copilot.command.cli.sessions.openRepository": "Open Repository",
"github.copilot.command.cli.sessions.openWorktreeInNewWindow": "Open Session in New Window",
"github.copilot.command.cli.sessions.openWorktreeInTerminal": "Open Session in Terminal",
diff --git a/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md b/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md
index fa726f71d883d..c439aa682639e 100644
--- a/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md
+++ b/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md
@@ -133,6 +133,20 @@ All interactions are displayed through VS Code's native chat UI, providing a sea
- Used to resume previous Claude Code conversations
- See `node/sessionParser/README.md` for detailed documentation
+### `node/claudeSkills.ts`
+
+**IClaudePluginService / ClaudePluginService**
+- Resolves plugin root directories for the Claude SDK's `plugins` option
+- Combines three sources of plugin locations:
+ 1. **Config skill locations** — from `chat.agentSkillsLocations` setting, resolved via the shared `resolveSkillConfigLocations()` utility. These point to skills directories (e.g. `.../skills/`), so the service walks **one level up** to reach the plugin root expected by the SDK.
+ 2. **Discovered skills** — from `IPromptsService.getSkills()`. Each skill has a `SKILL.md` at `/skills//SKILL.md`, so the service walks **three levels up** (`dirname(dirname(dirname(uri)))`) to reach the plugin root.
+ 3. **Direct plugins** — from `IPromptsService.getPlugins()`, returned as-is since they already point to plugin root directories.
+- Filters out `.claude` directories (the Claude SDK loads these automatically)
+- Deduplicates results using `ResourceSet`
+- Plugin roots are passed to the SDK as `SdkPluginConfig[]` with `{ type: 'local', path }` in `ClaudeCodeSession._doStartSession()`
+
+**Shared utility:** `../../common/skillConfigLocations.ts` — `resolveSkillConfigLocations()` handles `~/` expansion, absolute paths, and relative paths joined to workspace folders. Used by both `ClaudePluginService` and `CopilotCLISkills`.
+
### `common/claudeTools.ts`
Defines Claude Code's tool interface:
@@ -216,19 +230,56 @@ In multi-root and empty workspaces, a folder picker option appears in the chat s
- **`common/claudeFolderInfo.ts`**: `ClaudeFolderInfo` interface
- **`../../chatSessions/common/claudeWorkspaceFolderService.ts`**: `IClaudeWorkspaceFolderService` interface — computes git diff changes for session items
- **`../../chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.ts`**: Implementation — diffs the session's branch against its base branch, caches results, and maps changes to `ChatSessionChangedFile[]` for display in the Sessions view
-- **`../../chatSessions/vscode-node/claudeChatSessionContentProvider.ts`**: Folder resolution, picker options, and handler integration
+- **`../../chatSessions/vscode-node/claudeChatSessionContentProvider.ts`**: Folder resolution, picker options, session metadata enrichment, and git command handlers
+- **`../../chatSessions/common/builtinSlashCommands.ts`**: Shared constants for built-in slash commands (`/commit`, `/sync`, `/merge`, etc.) used by both Claude and CopilotCLI sessions
- **`../../chatSessions/vscode-node/folderRepositoryManagerImpl.ts`**: `FolderRepositoryManager` (abstract base) with `ClaudeFolderRepositoryManager` subclass — the Claude subclass does not depend on `ICopilotCLISessionService` (CopilotCLI has its own subclass `CopilotCLIFolderRepositoryManager`)
- **`node/claudeCodeAgent.ts`**: Consumes `ClaudeFolderInfo` in `ClaudeCodeSession._startSession()`
- **`node/sessionParser/claudeCodeSessionService.ts`**: `_getProjectSlugs()` generates slugs for all folders
+## Session Metadata and Git Commands
+
+### Session Metadata Enrichment
+
+Each Claude session item carries metadata that drives the Sessions view UI (button visibility, status indicators). The `ClaudeChatSessionItemController._buildSessionMetadata()` method enriches session items with git repository state:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `workingDirectoryPath` | `string` | Session's working directory (always present) |
+| `repositoryPath` | `string?` | Git repository root path |
+| `branchName` | `string?` | Current HEAD branch name |
+| `upstreamBranchName` | `string?` | Upstream tracking ref (e.g., `origin/main`) |
+| `hasGitHubRemote` | `boolean?` | Whether any remote points to GitHub |
+| `incomingChanges` | `number?` | Commits behind upstream |
+| `outgoingChanges` | `number?` | Commits ahead of upstream |
+| `uncommittedChanges` | `number?` | Total uncommitted changes (merge + index + working tree + untracked) |
+
+These metadata fields map to `when`-clause context keys in `package.json` (e.g., `sessions.hasGitRepository`, `sessions.hasUncommittedChanges`, `sessions.hasUpstream`) that control which action buttons appear in the Changes view.
+
+### Git Action Commands
+
+The `ClaudeChatSessionItemController` registers four git-related commands that appear as action buttons in the Sessions/Changes view:
+
+| Command | When Visible | Action |
+|---------|-------------|--------|
+| `github.copilot.claude.sessions.commit` | Has git repo + uncommitted changes | Sends `/commit` prompt to the session |
+| `github.copilot.claude.sessions.commitAndSync` | Has git repo + uncommitted changes + upstream | Sends `/commit and /sync` prompt |
+| `github.copilot.claude.sessions.sync` | Has git repo + no uncommitted changes + upstream | Sends `/sync` prompt |
+| `github.copilot.claude.sessions.initializeRepository` | No git repo | Calls `IGitService.initRepository()` on the session's workspace folder |
+
+The commit, commitAndSync, and sync commands use a shared `_registerPromptCommand()` helper that extracts the session resource and dispatches via `workbench.action.chat.openSessionWithPrompt.claude-code`. The slash command strings come from the shared `builtinSlashCommands` module (`../../common/builtinSlashCommands.ts`).
+
## Testing
Unit tests are located in `node/test/`:
- `claudeCodeAgent.spec.ts`: Tests for agent and session logic
- `claudeCodeSessionService.spec.ts`: Tests for session loading and persistence
+- `claudePluginService.spec.ts`: Tests for plugin location resolution
- `mockClaudeCodeSdkService.ts`: Mock SDK service for testing
- `fixtures/`: Sample `.jsonl` session files for testing
+Additional tests for the session item controller and content provider:
+- `../../chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts`: Tests for session metadata enrichment, git command handlers, session lifecycle, and content provider behavior
+
## Extension Registries
The Claude integration uses several registries to organize and manage extensibility points:
diff --git a/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md b/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md
index d2cb58a4587c2..be8d9f281e568 100644
--- a/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md
+++ b/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md
@@ -42,6 +42,7 @@ This guide covers the **Claude** session target in VS Code Copilot Chat: what it
- [Tools Available to Claude](#tools-available-to-claude)
- [Memory Files (CLAUDE.md)](#memory-files-claudemd)
- [Custom Subagents](#custom-subagents)
+- [Skills and Plugins](#skills-and-plugins)
- [Hooks](#hooks)
- [Settings Reference](#settings-reference)
- [How It Differs from Other Session Targets](#how-it-differs-from-other-session-targets)
@@ -229,6 +230,19 @@ Each session in the list displays:
Sessions are sorted by recency — the most recent session appears at the top. In the dedicated sidebar, they're also grouped by time period.
+#### Git Action Buttons
+
+When a session has a git repository, action buttons appear in the Changes view based on the repository state:
+
+| Button | When It Appears | What It Does |
+|--------|----------------|-------------|
+| **Commit** | Uncommitted changes exist | Sends `/commit` to the session — Claude stages and commits your changes |
+| **Commit and Sync** | Uncommitted changes exist + upstream branch configured | Sends `/commit and /sync` — Claude commits and pushes/pulls |
+| **Sync Changes** | No uncommitted changes + upstream branch configured | Sends `/sync` — Claude pushes/pulls with the remote |
+| **Initialize Repository** | No git repository in the session's working directory | Creates a new git repository in the session's folder |
+
+These buttons let you manage git operations without leaving the Sessions view or typing commands manually.
+
#### Searching and Filtering Sessions
The Sessions toolbar (above the session list) provides three tools:
@@ -565,6 +579,68 @@ Use the [`/agents`](#agents--create-and-manage-subagents) slash command to creat
---
+## Skills and Plugins
+
+Claude sessions automatically discover and load **skills** and **plugins** from your workspace and configuration. These extend Claude's capabilities with reusable, packaged functionality — such as custom slash commands, tools, or domain-specific instructions.
+
+### How Skills and Plugins Are Discovered
+
+Claude finds plugin directories from three sources:
+
+| Source | How It Works |
+|--------|-------------|
+| **`chat.agentSkillsLocations` setting** | Paths to directories containing skills. Claude walks one level up from each to find the plugin root. Supports `~/`, absolute, and relative paths. |
+| **Discovered `SKILL.md` files** | The prompts service finds `SKILL.md` files in your workspace. Claude derives the plugin root from each skill's file path. |
+| **Plugin directories** | The prompts service returns plugin root directories directly. |
+
+> **Note:** Directories under `.claude/` are automatically excluded since the Claude SDK loads those on its own.
+
+### Configuring Additional Skill Locations
+
+Use the `chat.agentSkillsLocations` setting to point Claude at additional skill directories:
+
+```json
+{
+ "chat.agentSkillsLocations": {
+ "~/my-skills": true,
+ "/absolute/path/to/skills": true,
+ "relative/skills": true
+ }
+}
+```
+
+- **`~/` paths** are expanded relative to your home directory
+- **Absolute paths** are used as-is
+- **Relative paths** are resolved against each workspace folder
+
+Set a path's value to `false` to disable it without removing the entry.
+
+### Skill File Format
+
+Each skill lives in its own directory with a `SKILL.md` file:
+
+```
+my-plugin/
+└── skills/
+ ├── my-skill/
+ │ └── SKILL.md
+ └── another-skill/
+ └── SKILL.md
+```
+
+`SKILL.md` files use YAML frontmatter for metadata:
+
+```markdown
+---
+name: my-skill
+description: "A brief description of what this skill does"
+---
+
+Instructions for Claude when this skill is invoked...
+```
+
+---
+
## Hooks
Hooks let you run custom scripts at key moments in Claude's execution. They're configured via the [`/hooks`](#hooks--configure-lifecycle-hooks) slash command and stored in VS Code settings.
diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts
index c2da7cb0a8255..e2b82b4beeee0 100644
--- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts
+++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import { EffortLevel, McpServerConfig, Options, PermissionMode, Query, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
+import { EffortLevel, McpServerConfig, Options, PermissionMode, Query, SDKUserMessage, SdkPluginConfig } from '@anthropic-ai/claude-agent-sdk';
import Anthropic from '@anthropic-ai/sdk';
import * as l10n from '@vscode/l10n';
import type * as vscode from 'vscode';
@@ -20,6 +20,7 @@ import { isWindows } from '../../../../util/vs/base/common/platform';
import { URI } from '../../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { LanguageModelToolMCPSource } from '../../../../vscodeTypes';
+import { IClaudePluginService } from './claudeSkills';
import { ExternalEditTracker } from '../../common/externalEditTracker';
import { buildMcpServersFromRegistry } from '../common/claudeMcpServerRegistry';
import { dispatchMessage, KnownClaudeError } from '../common/claudeMessageDispatch';
@@ -214,6 +215,7 @@ export class ClaudeCodeSession extends Disposable {
@IClaudeSessionStateService private readonly sessionStateService: IClaudeSessionStateService,
@IClaudeRuntimeDataService private readonly runtimeDataService: IClaudeRuntimeDataService,
@IMcpService private readonly mcpService: IMcpService,
+ @IClaudePluginService private readonly claudePluginService: IClaudePluginService,
@IOTelService private readonly _otelService: IOTelService,
@IChatDebugFileLoggerService private readonly _debugFileLogger: IChatDebugFileLoggerService,
) {
@@ -429,6 +431,22 @@ export class ClaudeCodeSession extends Disposable {
const errorMessage = error instanceof Error ? (error.stack ?? error.message) : String(error);
this.logService.warn(`[ClaudeCodeSession] Failed to start MCP gateway: ${errorMessage}`);
}
+
+ // Build plugins from skill directories
+ const plugins: SdkPluginConfig[] = [];
+ try {
+ const pluginLocations = await this.claudePluginService.getPluginLocations(token);
+ for (const pluginLocation of pluginLocations) {
+ plugins.push({ type: 'local', path: pluginLocation.fsPath });
+ }
+ if (plugins.length > 0) {
+ this.logService.info(`[ClaudeCodeSession] Passing ${plugins.length} plugin(s) from skill locations`);
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? (error.stack ?? error.message) : String(error);
+ this.logService.warn(`[ClaudeCodeSession] Failed to resolve skill locations for plugins: ${errorMessage}`);
+ }
+
const options: Options = {
cwd,
additionalDirectories,
@@ -451,6 +469,7 @@ export class ClaudeCodeSession extends Disposable {
permissionMode: this._currentPermissionMode,
includeHookEvents: true,
mcpServers,
+ plugins,
settings: {
env: {
ANTHROPIC_BASE_URL: `http://localhost:${this.serverConfig.port}`,
diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSkills.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSkills.ts
new file mode 100644
index 0000000000000..8e55811bf7421
--- /dev/null
+++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSkills.ts
@@ -0,0 +1,82 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import type { Uri } from 'vscode';
+import { IConfigurationService } from '../../../../platform/configuration/common/configurationService';
+import { INativeEnvService } from '../../../../platform/env/common/envService';
+import { IPromptsService } from '../../../../platform/promptFiles/common/promptsService';
+import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
+import { createServiceIdentifier } from '../../../../util/common/services';
+import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
+import { Disposable } from '../../../../util/vs/base/common/lifecycle';
+import { ResourceSet } from '../../../../util/vs/base/common/map';
+import { Schemas } from '../../../../util/vs/base/common/network';
+import { dirname } from '../../../../util/vs/base/common/resources';
+import { URI } from '../../../../util/vs/base/common/uri';
+import { resolveSkillConfigLocations } from '../../common/skillConfigLocations';
+
+/** The Claude SDK loads `.claude` directories automatically — skip them to avoid duplicates. */
+function isClaudeDirectory(uri: URI): boolean {
+ return uri.path.split('/').includes('.claude');
+}
+
+export interface IClaudePluginService {
+ readonly _serviceBrand: undefined;
+ /**
+ * Returns plugin root directories suitable for the Claude SDK's `plugins` option.
+ *
+ * Combines two sources:
+ * 1. **Skills** — discovered as directories containing `SKILL.md` files, but the Claude SDK
+ * plugin loader expects the *parent* of the `skills/` directory (the plugin root),
+ * so we walk one level up from each skill location.
+ * 2. **Plugins** — returned directly by the prompts service as actual plugin root directories.
+ */
+ getPluginLocations(token: CancellationToken): Promise;
+}
+
+export const IClaudePluginService = createServiceIdentifier('IClaudePluginService');
+
+export class ClaudePluginService extends Disposable implements IClaudePluginService {
+ declare _serviceBrand: undefined;
+
+ constructor(
+ @IConfigurationService private readonly configurationService: IConfigurationService,
+ @INativeEnvService private readonly envService: INativeEnvService,
+ @IWorkspaceService private readonly workspaceService: IWorkspaceService,
+ @IPromptsService private readonly promptsService: IPromptsService,
+ ) {
+ super();
+ }
+
+ async getPluginLocations(token: CancellationToken): Promise {
+ const pluginRoots = new ResourceSet();
+
+ // #region Skills as plugin roots
+ // Skill locations point to directories containing skill subdirectories (e.g. .../skills/).
+ // The Claude SDK plugin loader expects the parent of the skills/ directory, so we
+ // walk one level up from each location.
+ for (const uri of resolveSkillConfigLocations(this.configurationService, this.envService, this.workspaceService)) {
+ pluginRoots.add(dirname(uri));
+ }
+
+ (await this.promptsService.getSkills(token))
+ .filter(s => s.uri.scheme === Schemas.file)
+ .map(s => s.uri)
+ .map(uri => dirname(dirname(dirname(uri))))
+ .filter(uri => !isClaudeDirectory(uri))
+ .forEach(uri => pluginRoots.add(uri));
+ // #endregion
+
+ // #region Plugin roots from prompts service
+ (await this.promptsService.getPlugins(token))
+ .filter(p => p.uri.scheme === Schemas.file)
+ .filter(p => !isClaudeDirectory(p.uri))
+ .map(p => p.uri)
+ .forEach(uri => pluginRoots.add(uri));
+ // #endregion
+
+ return Array.from(pluginRoots);
+ }
+}
diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudePluginService.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudePluginService.spec.ts
new file mode 100644
index 0000000000000..f7167fff5d788
--- /dev/null
+++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudePluginService.spec.ts
@@ -0,0 +1,231 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import type { ChatPlugin, ChatSkill } from 'vscode';
+import { IConfigurationService } from '../../../../../platform/configuration/common/configurationService';
+import { InMemoryConfigurationService } from '../../../../../platform/configuration/test/common/inMemoryConfigurationService';
+import { SKILLS_LOCATION_KEY } from '../../../../../platform/customInstructions/common/promptTypes';
+import { INativeEnvService } from '../../../../../platform/env/common/envService';
+import { NullNativeEnvService } from '../../../../../platform/env/common/nullEnvService';
+import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';
+import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';
+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 { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';
+import { createExtensionUnitTestingServices } from '../../../../test/node/services';
+import { ClaudePluginService } from '../claudeSkills';
+import { IPromptsService } from '../../../../../platform/promptFiles/common/promptsService';
+
+const ClaudePluginServiceConstructor = ClaudePluginService as unknown as new (
+ configurationService: IConfigurationService,
+ envService: INativeEnvService,
+ workspaceService: IWorkspaceService,
+ promptsService: IPromptsService,
+) => ClaudePluginService;
+
+function createWorkspaceService(folders: URI[] = [URI.file('/workspace')]): IWorkspaceService {
+ return {
+ _serviceBrand: undefined,
+ onDidChangeWorkspaceFolders: Event.None,
+ getWorkspaceFolders: () => folders,
+ } as unknown as IWorkspaceService;
+}
+
+function mockSkill(uri: string, name: string): ChatSkill {
+ return { uri: URI.parse(uri), name } as ChatSkill;
+}
+
+function mockPlugin(uri: string): ChatPlugin {
+ return { uri: URI.parse(uri) } as ChatPlugin;
+}
+
+describe('ClaudePluginService', () => {
+ const disposables = new DisposableStore();
+ let baseConfigurationService: IConfigurationService;
+
+ beforeEach(() => {
+ const services = disposables.add(createExtensionUnitTestingServices());
+ const accessor = services.createTestingAccessor();
+ baseConfigurationService = accessor.get(IConfigurationService);
+ });
+
+ afterEach(() => {
+ disposables.clear();
+ });
+
+ function createService(options?: {
+ configLocations?: Record;
+ workspaceFolders?: URI[];
+ skills?: readonly ChatSkill[];
+ plugins?: readonly ChatPlugin[];
+ userHome?: URI;
+ }): ClaudePluginService {
+ const configService = new InMemoryConfigurationService(baseConfigurationService);
+ if (options?.configLocations) {
+ configService.setNonExtensionConfig(SKILLS_LOCATION_KEY, options.configLocations);
+ }
+
+ const envService = options?.userHome
+ ? new class extends NullNativeEnvService { override get userHome() { return options.userHome!; } }()
+ : new NullNativeEnvService();
+
+ const promptsService = disposables.add(new MockPromptsService());
+ if (options?.skills) {
+ promptsService.setSkills(options.skills);
+ }
+ if (options?.plugins) {
+ promptsService.setPlugins(options.plugins);
+ }
+
+ const service = new ClaudePluginServiceConstructor(
+ configService,
+ envService,
+ createWorkspaceService(options?.workspaceFolders),
+ promptsService,
+ );
+ disposables.add(service);
+ return service;
+ }
+
+ it('returns empty array when no config, no skills, and no plugins', async () => {
+ const service = createService();
+ expect(await service.getPluginLocations(CancellationToken.None)).toEqual([]);
+ });
+
+ // #region Config-based skill locations (walks one level up)
+
+ it('walks one level up from config skill locations to get plugin roots', async () => {
+ const service = createService({
+ configLocations: { '/projects/my-extension/skills': true },
+ });
+ const locations = await service.getPluginLocations(CancellationToken.None);
+ expect(locations).toHaveLength(1);
+ expect(locations[0].path).toBe('/projects/my-extension');
+ });
+
+ it('resolves tilde paths from config and walks up', async () => {
+ const service = createService({
+ configLocations: { '~/skills': true },
+ userHome: URI.file('/home/user'),
+ });
+ const locations = await service.getPluginLocations(CancellationToken.None);
+ expect(locations).toHaveLength(1);
+ expect(locations[0].path).toBe('/home/user');
+ });
+
+ it('resolves relative config paths per workspace folder and walks up', async () => {
+ const service = createService({
+ configLocations: { 'skills': true },
+ workspaceFolders: [URI.file('/workspace1'), URI.file('/workspace2')],
+ });
+ const locations = await service.getPluginLocations(CancellationToken.None);
+ expect(locations).toHaveLength(2);
+ expect(locations[0].path).toBe('/workspace1');
+ expect(locations[1].path).toBe('/workspace2');
+ });
+
+ // #endregion
+
+ // #region Skills from prompts service (walks three levels up from SKILL.md)
+
+ it('derives plugin roots from SKILL.md URIs by walking three levels up', async () => {
+ const service = createService({
+ skills: [mockSkill('/plugins/my-plugin/skills/my-skill/SKILL.md', 'my-skill')],
+ });
+ const locations = await service.getPluginLocations(CancellationToken.None);
+ expect(locations).toHaveLength(1);
+ expect(locations[0].path).toBe('/plugins/my-plugin');
+ });
+
+ it('deduplicates skills from the same plugin root', async () => {
+ const service = createService({
+ skills: [
+ mockSkill('/plugins/my-plugin/skills/skill-a/SKILL.md', 'skill-a'),
+ mockSkill('/plugins/my-plugin/skills/skill-b/SKILL.md', 'skill-b'),
+ ],
+ });
+ const locations = await service.getPluginLocations(CancellationToken.None);
+ expect(locations).toHaveLength(1);
+ expect(locations[0].path).toBe('/plugins/my-plugin');
+ });
+
+ it('filters out non-file-scheme skills', async () => {
+ const service = createService({
+ skills: [mockSkill('copilot-skill:/remote/skills/my-skill/SKILL.md', 'remote')],
+ });
+ const locations = await service.getPluginLocations(CancellationToken.None);
+ expect(locations).toHaveLength(0);
+ });
+
+ it('filters out skills inside .claude directories', async () => {
+ const service = createService({
+ skills: [mockSkill('/projects/my-project/.claude/skills/my-skill/SKILL.md', 'my-skill')],
+ });
+ const locations = await service.getPluginLocations(CancellationToken.None);
+ expect(locations).toHaveLength(0);
+ });
+
+ // #endregion
+
+ // #region Plugin roots from prompts service
+
+ it('includes plugin roots from prompts service', async () => {
+ const service = createService({
+ plugins: [mockPlugin('/plugins/external-plugin')],
+ });
+ const locations = await service.getPluginLocations(CancellationToken.None);
+ expect(locations).toHaveLength(1);
+ expect(locations[0].path).toBe('/plugins/external-plugin');
+ });
+
+ it('filters out non-file-scheme plugins', async () => {
+ const service = createService({
+ plugins: [mockPlugin('copilot-plugin:/remote/plugin')],
+ });
+ const locations = await service.getPluginLocations(CancellationToken.None);
+ expect(locations).toHaveLength(0);
+ });
+
+ it('filters out plugins inside .claude directories', async () => {
+ const service = createService({
+ plugins: [mockPlugin('/projects/my-project/.claude')],
+ });
+ const locations = await service.getPluginLocations(CancellationToken.None);
+ expect(locations).toHaveLength(0);
+ });
+
+ // #endregion
+
+ // #region Deduplication across all sources
+
+ it('deduplicates across config locations, skills, and plugins', async () => {
+ const service = createService({
+ configLocations: { '/my-plugin/skills': true },
+ skills: [mockSkill('/my-plugin/skills/skill-a/SKILL.md', 'skill-a')],
+ plugins: [mockPlugin('/my-plugin')],
+ });
+ const locations = await service.getPluginLocations(CancellationToken.None);
+ expect(locations).toHaveLength(1);
+ expect(locations[0].path).toBe('/my-plugin');
+ });
+
+ it('combines distinct locations from all sources', async () => {
+ const service = createService({
+ configLocations: { '/config-plugin/skills': true },
+ skills: [mockSkill('/skill-plugin/skills/my-skill/SKILL.md', 'my-skill')],
+ plugins: [mockPlugin('/direct-plugin')],
+ });
+ const locations = await service.getPluginLocations(CancellationToken.None);
+ const paths = locations.map(l => l.path);
+ expect(paths).toContain('/config-plugin');
+ expect(paths).toContain('/skill-plugin');
+ expect(paths).toContain('/direct-plugin');
+ expect(locations).toHaveLength(3);
+ });
+
+ // #endregion
+});
diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/test/skillConfigLocations.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/node/test/skillConfigLocations.spec.ts
new file mode 100644
index 0000000000000..a4da32e481b1a
--- /dev/null
+++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/skillConfigLocations.spec.ts
@@ -0,0 +1,136 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { IConfigurationService } from '../../../../../platform/configuration/common/configurationService';
+import { InMemoryConfigurationService } from '../../../../../platform/configuration/test/common/inMemoryConfigurationService';
+import { SKILLS_LOCATION_KEY } from '../../../../../platform/customInstructions/common/promptTypes';
+import { INativeEnvService } from '../../../../../platform/env/common/envService';
+import { NullNativeEnvService } from '../../../../../platform/env/common/nullEnvService';
+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';
+import { resolveSkillConfigLocations } from '../../../common/skillConfigLocations';
+
+function createWorkspaceService(folders: URI[] = [URI.file('/workspace')]): IWorkspaceService {
+ return {
+ _serviceBrand: undefined,
+ onDidChangeWorkspaceFolders: Event.None,
+ getWorkspaceFolders: () => folders,
+ } as unknown as IWorkspaceService;
+}
+
+describe('resolveSkillConfigLocations', () => {
+ const disposables = new DisposableStore();
+ let baseConfigurationService: IConfigurationService;
+
+ beforeEach(() => {
+ const services = disposables.add(createExtensionUnitTestingServices());
+ const accessor = services.createTestingAccessor();
+ baseConfigurationService = accessor.get(IConfigurationService);
+ });
+
+ afterEach(() => {
+ disposables.clear();
+ });
+
+ function resolve(options?: {
+ configLocations?: Record;
+ workspaceFolders?: URI[];
+ userHome?: URI;
+ }): URI[] {
+ const configService = new InMemoryConfigurationService(baseConfigurationService);
+ if (options?.configLocations) {
+ configService.setNonExtensionConfig(SKILLS_LOCATION_KEY, options.configLocations);
+ }
+
+ const envService: INativeEnvService = options?.userHome
+ ? new class extends NullNativeEnvService { override get userHome() { return options.userHome!; } }()
+ : new NullNativeEnvService();
+
+ const workspaceService = createWorkspaceService(options?.workspaceFolders);
+
+ return resolveSkillConfigLocations(configService, envService, workspaceService);
+ }
+
+ it('returns empty array when no config is set', () => {
+ expect(resolve()).toEqual([]);
+ });
+
+ it('returns empty array when config is not an object', () => {
+ const configService = new InMemoryConfigurationService(baseConfigurationService);
+ configService.setNonExtensionConfig(SKILLS_LOCATION_KEY, 'not-an-object');
+ const result = resolveSkillConfigLocations(
+ configService,
+ new NullNativeEnvService(),
+ createWorkspaceService(),
+ );
+ expect(result).toEqual([]);
+ });
+
+ it('expands tilde-prefixed paths using user home directory', () => {
+ const result = resolve({
+ configLocations: { '~/my-skills': true },
+ userHome: URI.file('/home/user'),
+ });
+ expect(result).toHaveLength(1);
+ expect(result[0].path).toBe('/home/user/my-skills');
+ });
+
+ it('handles absolute paths', () => {
+ const result = resolve({
+ configLocations: { '/absolute/skills/path': true },
+ });
+ expect(result).toHaveLength(1);
+ expect(result[0].path).toBe('/absolute/skills/path');
+ });
+
+ it('joins relative paths to each workspace folder', () => {
+ const result = resolve({
+ configLocations: { 'relative/skills': true },
+ workspaceFolders: [URI.file('/workspace1'), URI.file('/workspace2')],
+ });
+ expect(result).toHaveLength(2);
+ expect(result[0].path).toBe('/workspace1/relative/skills');
+ expect(result[1].path).toBe('/workspace2/relative/skills');
+ });
+
+ it('ignores config entries with value !== true', () => {
+ const result = resolve({
+ configLocations: {
+ '/included': true,
+ '/excluded': false,
+ },
+ });
+ expect(result).toHaveLength(1);
+ expect(result[0].path).toBe('/included');
+ });
+
+ it('handles mixed path types', () => {
+ const result = resolve({
+ configLocations: {
+ '~/home-skills': true,
+ '/absolute-skills': true,
+ 'relative-skills': true,
+ },
+ userHome: URI.file('/home/user'),
+ workspaceFolders: [URI.file('/workspace')],
+ });
+ expect(result).toHaveLength(3);
+ expect(result[0].path).toBe('/home/user/home-skills');
+ expect(result[1].path).toBe('/absolute-skills');
+ expect(result[2].path).toBe('/workspace/relative-skills');
+ });
+
+ it('trims whitespace from location keys', () => {
+ const result = resolve({
+ configLocations: { ' /trimmed ': true },
+ });
+ expect(result).toHaveLength(1);
+ expect(result[0].path).toBe('/trimmed');
+ });
+});
diff --git a/extensions/copilot/src/extension/chatSessions/common/builtinSlashCommands.ts b/extensions/copilot/src/extension/chatSessions/common/builtinSlashCommands.ts
new file mode 100644
index 0000000000000..b10640859a83d
--- /dev/null
+++ b/extensions/copilot/src/extension/chatSessions/common/builtinSlashCommands.ts
@@ -0,0 +1,16 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+/**
+ * Built-in slash commands used in the UI of Agents app.
+ */
+export const builtinSlashCommands = {
+ commit: '/commit',
+ sync: '/sync',
+ merge: '/merge',
+ createPr: '/create-pr',
+ createDraftPr: '/create-draft-pr',
+ updatePr: '/update-pr',
+};
diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts
index 82e0b0316a7c1..f3e4d58d67390 100644
--- a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts
+++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorkspaceFolderService.ts
@@ -59,4 +59,6 @@ export interface IChatSessionWorkspaceFolderService {
* Returns the affected session IDs.
*/
clearWorkspaceChanges(folderUri: vscode.Uri): string[];
+
+ hasCachedChanges(sessionId: string): Promise;
}
diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts
index 6b442ce04ec73..e26a50bf2c476 100644
--- a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts
+++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts
@@ -75,7 +75,7 @@ export interface IChatSessionWorktreeService {
getWorktreeChanges(sessionId: string): Promise;
- hasWorktreeChanges(sessionId: string): Promise;
+ hasCachedChanges(sessionId: string): Promise;
handleRequestCompleted(sessionId: string): Promise;
diff --git a/extensions/copilot/src/extension/chatSessions/common/skillConfigLocations.ts b/extensions/copilot/src/extension/chatSessions/common/skillConfigLocations.ts
new file mode 100644
index 0000000000000..68e91b0e848cd
--- /dev/null
+++ b/extensions/copilot/src/extension/chatSessions/common/skillConfigLocations.ts
@@ -0,0 +1,48 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { IConfigurationService } from '../../../platform/configuration/common/configurationService';
+import { SKILLS_LOCATION_KEY } from '../../../platform/customInstructions/common/promptTypes';
+import { INativeEnvService } from '../../../platform/env/common/envService';
+import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
+import { isAbsolute } from '../../../util/vs/base/common/path';
+import { isObject } from '../../../util/vs/base/common/types';
+import { URI } from '../../../util/vs/base/common/uri';
+
+/**
+ * Resolves skill directory locations from the `chat.agentSkillsLocations` config setting.
+ * Handles `~/` expansion, absolute paths, and relative paths (joined to each workspace folder).
+ */
+export function resolveSkillConfigLocations(
+ configurationService: IConfigurationService,
+ envService: INativeEnvService,
+ workspaceService: IWorkspaceService,
+): URI[] {
+ const results: URI[] = [];
+ const locations = configurationService.getNonExtensionConfig>(SKILLS_LOCATION_KEY);
+ if (!isObject(locations)) {
+ return results;
+ }
+
+ const userHome = envService.userHome;
+ const workspaceFolders = workspaceService.getWorkspaceFolders();
+ for (const key in locations) {
+ const location = key.trim();
+ if (locations[key] !== true) {
+ continue;
+ }
+ if (location.startsWith('~/')) {
+ results.push(URI.joinPath(userHome, location.substring(2)));
+ } else if (isAbsolute(location)) {
+ results.push(URI.file(location));
+ } else {
+ for (const workspaceFolder of workspaceFolders) {
+ results.push(URI.joinPath(workspaceFolder, location));
+ }
+ }
+ }
+
+ return results;
+}
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCLISkills.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCLISkills.ts
index 22b2f64f17df7..1807bd6ec6af8 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCLISkills.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCLISkills.ts
@@ -5,7 +5,6 @@
import type { Uri } from 'vscode';
import { IConfigurationService } from '../../../../platform/configuration/common/configurationService';
-import { SKILLS_LOCATION_KEY } from '../../../../platform/customInstructions/common/promptTypes';
import { INativeEnvService } from '../../../../platform/env/common/envService';
import { ILogService } from '../../../../platform/log/common/logService';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
@@ -13,16 +12,12 @@ import { createServiceIdentifier } from '../../../../util/common/services';
import { Disposable } from '../../../../util/vs/base/common/lifecycle';
import { ResourceSet } from '../../../../util/vs/base/common/map';
import { Schemas } from '../../../../util/vs/base/common/network';
-import { isAbsolute } from '../../../../util/vs/base/common/path';
-import {
- dirname
-} from '../../../../util/vs/base/common/resources';
-import { isObject } from '../../../../util/vs/base/common/types';
-import { URI } from '../../../../util/vs/base/common/uri';
+import { dirname } from '../../../../util/vs/base/common/resources';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { IPromptsService } from '../../../../platform/promptFiles/common/promptsService';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
import { isEnabledForCopilotCLI } from './copilotCli';
+import { resolveSkillConfigLocations } from '../../common/skillConfigLocations';
export interface ICopilotCLISkills {
readonly _serviceBrand: undefined;
@@ -45,30 +40,9 @@ export class CopilotCLISkills extends Disposable implements ICopilotCLISkills {
}
public async getSkillsLocations(token: CancellationToken): Promise {
- // Get additional skill locations from config
const configSkillLocationUris = new ResourceSet();
- const locations = this.configurationService.getNonExtensionConfig>(SKILLS_LOCATION_KEY);
- const userHome = this.envService.userHome;
- const workspaceFolders = this.workspaceService.getWorkspaceFolders();
- if (isObject(locations)) {
- for (const key in locations) {
- const location = key.trim();
- const value = locations[key];
- if (value !== true) {
- continue;
- }
- // Expand ~/ to user home directory
- if (location.startsWith('~/')) {
- configSkillLocationUris.add(URI.joinPath(userHome, location.substring(2)));
- } else if (isAbsolute(location)) {
- configSkillLocationUris.add(URI.file(location));
- } else {
- // Relative path - join to each workspace folder
- for (const workspaceFolder of workspaceFolders) {
- configSkillLocationUris.add(URI.joinPath(workspaceFolder, location));
- }
- }
- }
+ for (const uri of resolveSkillConfigLocations(this.configurationService, this.envService, this.workspaceService)) {
+ configSkillLocationUris.add(uri);
}
(await this.promptsService.getSkills(token))
.filter(isEnabledForCopilotCLI)
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts
index f476606bf274b..0152d190f03db 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts
@@ -93,14 +93,7 @@ interface McSharedState {
}
const mcStateBySessionId = new Map();
-export const builtinSlashSCommands = {
- commit: '/commit',
- sync: '/sync',
- merge: '/merge',
- createPr: '/create-pr',
- createDraftPr: '/create-draft-pr',
- updatePr: '/update-pr',
-};
+export { builtinSlashCommands as builtinSlashSCommands } from '../../common/builtinSlashCommands';
/**
* Either a free-form prompt **or** a known command.
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts
index 3a47cf7d6ee94..49ed9d73bf902 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import type { internal, LocalSessionMetadata, Session, SessionContext, SessionEvent, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';
+import type { internal, LocalSession, LocalSessionMetadata, Session, SessionContext, SessionEvent, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';
import * as l10n from '@vscode/l10n';
import { createReadStream } from 'node:fs';
import { devNull } from 'node:os';
@@ -89,6 +89,7 @@ export interface ICopilotCLISessionService {
// Session metadata querying
getSessionItem(sessionId: string, token: CancellationToken): Promise;
+ getSessionTitle(sessionId: string, token: CancellationToken): Promise;
getAllSessions(token: CancellationToken): Promise;
// SDK session management
@@ -98,6 +99,7 @@ export interface ICopilotCLISessionService {
// Session rename
renameSession(sessionId: string, title: string): Promise;
+ updateSessionSummary(sessionId: string, title: string): Promise;
// Session wrapper tracking
getSession(options: IGetSessionOptions, token: CancellationToken): Promise | undefined>;
@@ -362,35 +364,55 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
}
public async getSessionTitle(sessionId: string, token: CancellationToken): Promise {
- return this.getSessionTitleImpl(sessionId, undefined, token);
+ const sessionManager = await this.getSessionManager();
+ const metadata = await sessionManager.getSessionMetadata({ sessionId });
+ return this.getSessionTitleImpl(sessionId, metadata, token);
}
/**
- * Gets the session title.
- * Always give preference to label defined by user, then title from CLI session object.
- * If we have the metadata then use that over extracting label ourselves or using any cache.
+ * Single source of truth for both `getSessionTitle()` (editor/header) and
+ * `_getAllSessions()` (sidebar list) so the two surfaces never diverge.
+ *
+ * Precedence:
+ * 1. Explicit renamed title — active wrapper title, SDK `name`, or legacy custom title.
+ * 2. Cached derived label in `_sessionLabels` (from a previous history scan).
+ * 3. Pending prompt for in-flight new sessions.
+ * 4. Clean metadata `summary` (rejected if it looks truncated).
+ * 5. First user message from session history (cached on success).
+ * 6. Raw metadata `summary` as a display-only last resort (not cached).
*/
private async getSessionTitleImpl(sessionId: string, metadata: LocalSessionMetadata | undefined, token: CancellationToken): Promise {
- // Always give preference to label defined by user, then title from CLI and finally label from prompt summary. This is to ensure that if user has renamed the session, we do not override that with title from CLI or label from prompt.
- const accurateTitle = await this.customSessionTitleService.getCustomSessionTitle(sessionId) ??
- labelFromPrompt(this._sessionWrappers.get(sessionId)?.object.pendingPrompt ?? '') ??
- this._sessionWrappers.get(sessionId)?.object.title;
+ const explicitTitle =
+ this._sessionWrappers.get(sessionId)?.object.title ??
+ metadata?.name ??
+ await this.customSessionTitleService.getCustomSessionTitle(sessionId);
+ if (explicitTitle) {
+ return explicitTitle;
+ }
+
+ const cached = this._sessionLabels.get(sessionId);
+ if (cached) {
+ return cached;
+ }
- if (accurateTitle) {
- return accurateTitle;
+ const pendingLabel = labelFromPrompt(this._sessionWrappers.get(sessionId)?.object.pendingPrompt ?? '');
+ if (pendingLabel) {
+ return pendingLabel;
}
const summarizedTitle = labelFromPrompt(metadata?.summary ?? '');
- if (summarizedTitle) {
- if (summarizedTitle.endsWith('...')) {
- // If the SDK is going to just give us a truncated version of the first user message as the summary, then we might as well extract the label ourselves from the first user message instead of using the truncated summary.
- } else {
- return summarizedTitle;
- }
+ if (summarizedTitle && !summarizedTitle.endsWith('...') && !summarizedTitle.includes('<')) {
+ return summarizedTitle;
}
const firstUserMessage = await this.getFirstUserMessageFromSession(sessionId, token);
- return labelFromPrompt(firstUserMessage ?? '');
+ const fromHistory = labelFromPrompt(firstUserMessage ?? '');
+ if (fromHistory) {
+ this._sessionLabels.set(sessionId, fromHistory);
+ return fromHistory;
+ }
+
+ return metadata?.summary ?? '';
}
@@ -426,35 +448,16 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
const id = metadata.sessionId;
const startTime = metadata.startTime.getTime();
const endTime = metadata.modifiedTime.getTime();
- const label = await this.customSessionTitleService.getCustomSessionTitle(metadata.sessionId) ?? this._sessionWrappers.get(metadata.sessionId)?.object.title ?? this._sessionLabels.get(metadata.sessionId) ?? (metadata.summary ? labelFromPrompt(metadata.summary) : undefined);
- // CLI adds `` tags to user prompt, this needs to be removed.
- // However in summary CLI can end up truncating the prompt and adding `... !diskSessionIds.has(session.object.sessionId))
.filter(session => session.object.status === ChatSessionStatus.InProgress)
.map(async (session): Promise => {
- const label = await this.customSessionTitleService.getCustomSessionTitle(session.object.sessionId) ?? labelFromPrompt(session.object.pendingPrompt ?? '');
+ const label = session.object.title ?? await this.customSessionTitleService.getCustomSessionTitle(session.object.sessionId) ?? labelFromPrompt(session.object.pendingPrompt ?? '');
if (!label) {
return;
}
@@ -548,12 +551,19 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
const sessionOptions = await this.createSessionsOptions({ ...options, mcpServers });
const sessionManager = await raceCancellationError(this.getSessionManager(), token);
const sdkSession = await sessionManager.createSession({ ...sessionOptions, sessionId: options.sessionId });
- this._newSessionIds.delete(sdkSession.sessionId);
+ const wasNewSession = this._newSessionIds.delete(sdkSession.sessionId);
// After the first session creation, the SDK's OTel TracerProvider is
// initialized. Install the bridge processor so SDK-native spans flow
// to the debug panel.
this._installBridgeIfNeeded();
+
+ if (wasNewSession) {
+ const stagedTitle = await this.customSessionTitleService.getCustomSessionTitle(sdkSession.sessionId);
+ if (stagedTitle) {
+ await sdkSession.updateSessionSummary(stagedTitle);
+ }
+ }
if (sessionOptions.copilotUrl) {
sdkSession.setAuthInfo({
type: 'hmac',
@@ -1110,9 +1120,46 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
}
}
+ private async updateSdkSessionMetadata(sessionId: string, title: string, operation: (sdkSession: LocalSession) => Promise): Promise {
+ let sessionManager: internal.LocalSessionManager | undefined;
+ let shouldCloseSession = false;
+ const sdkSession = (this._sessionWrappers.get(sessionId)?.object.sdkSession as LocalSession | undefined) ?? await (async () => {
+ sessionManager = await this.getSessionManager();
+ const session = await sessionManager.getSession({ sessionId }, true) as LocalSession | undefined;
+ shouldCloseSession = !!session;
+ return session;
+ })();
+
+ if (!sdkSession) {
+ // SDK session not yet materialized (e.g. brand-new VS Code sessionId).
+ // Stage locally; `createSession` syncs it into the SDK once the session is created.
+ await this.customSessionTitleService.setCustomSessionTitle(sessionId, title);
+ return;
+ }
+
+ try {
+ await operation(sdkSession);
+ } finally {
+ if (shouldCloseSession && sessionManager) {
+ await sessionManager.closeSession(sessionId).catch(error => {
+ this.logService.error(`[CopilotCLISession] Failed to close session ${sessionId} after updating title metadata: ${error}`);
+ });
+ }
+ }
+ }
+
public async renameSession(sessionId: string, title: string): Promise {
- await this.customSessionTitleService.setCustomSessionTitle(sessionId, title);
- this._sessionLabels.set(sessionId, title);
+ await this.updateSdkSessionMetadata(sessionId, title, sdkSession => sdkSession.renameSession(title));
+ this._sessionLabels.delete(sessionId);
+ this._onDidChangeSessions.fire();
+ }
+
+ public async updateSessionSummary(sessionId: string, title: string): Promise {
+ await this.updateSdkSessionMetadata(sessionId, title, sdkSession => sdkSession.updateSessionSummary(title));
+ // Invalidate the derived-label cache so a subsequent title resolution
+ // can pick up the freshly-written summary instead of returning a stale
+ // label that was extracted from session history on a prior pass.
+ this._sessionLabels.delete(sessionId);
this._onDidChangeSessions.fire();
}
}
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts
index 66b87def8c573..8a81f822de1fa 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts
@@ -77,8 +77,11 @@ class NullChatSessionWorktreeService extends mock()
class NullCustomSessionTitleService implements ICustomSessionTitleService {
declare _serviceBrand: undefined;
- async getCustomSessionTitle(_sessionId: string): Promise { return undefined; }
- async setCustomSessionTitle(_sessionId: string, _title: string): Promise { }
+ private readonly titles = new Map();
+ async getCustomSessionTitle(sessionId: string): Promise { return this.titles.get(sessionId); }
+ async setCustomSessionTitle(sessionId: string, title: string): Promise {
+ this.titles.set(sessionId, title);
+ }
async generateSessionTitle(_sessionId: string, _request: { prompt?: string; command?: string }): Promise { return undefined; }
}
@@ -333,6 +336,49 @@ describe('CopilotCLISessionService', () => {
});
});
+ describe('CopilotCLISessionService.renameSession', () => {
+ it('renames an inactive session through copilot/sdk', async () => {
+ const sessionId = 'rename-inactive';
+ manager.sessions.set(sessionId, new MockCliSdkSession(sessionId, new Date()));
+
+ await service.renameSession(sessionId, 'Renamed From VS Code');
+
+ expect(manager.sessions.get(sessionId)?.title).toBe('Renamed From VS Code');
+ expect(await service.getSessionTitle(sessionId, CancellationToken.None)).toBe('Renamed From VS Code');
+ });
+
+ it('renames an active wrapped session through copilot/sdk', async () => {
+ const session = await service.createSession({ sessionId: 'rename-active', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
+
+ await service.renameSession(session.object.sessionId, 'Wrapped Session Name');
+
+ expect(manager.sessions.get(session.object.sessionId)?.title).toBe('Wrapped Session Name');
+ expect(await service.getSessionTitle(session.object.sessionId, CancellationToken.None)).toBe('Wrapped Session Name');
+ session.dispose();
+ });
+
+ it('updates session summaries through copilot/sdk for untitled sessions', async () => {
+ const sessionId = 'summary-session';
+ manager.sessions.set(sessionId, new MockCliSdkSession(sessionId, new Date()));
+
+ await service.updateSessionSummary(sessionId, 'Generated Summary');
+
+ expect(manager.sessions.get(sessionId)?.summary).toBe('Generated Summary');
+ expect(await service.getSessionTitle(sessionId, CancellationToken.None)).toBe('Generated Summary');
+ });
+
+ it('syncs staged titles for newly created vscode sessions into copilot/sdk', async () => {
+ const sessionId = service.createNewSessionId();
+ await (service as unknown as { customSessionTitleService: ICustomSessionTitleService }).customSessionTitleService.setCustomSessionTitle(sessionId, 'Staged Session Title');
+
+ const session = await service.createSession({ sessionId, ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
+
+ expect(manager.sessions.get(sessionId)?.summary).toBe('Staged Session Title');
+ expect(await service.getSessionTitle(sessionId, CancellationToken.None)).toBe('Staged Session Title');
+ session.dispose();
+ });
+ });
+
describe('CopilotCLISessionService.tryGetPartialSesionHistory', () => {
it('reconstructs history from persisted files', async () => {
tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts
index 19ee9639ceb1c..cb0227f283fb0 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/testHelpers.ts
@@ -19,6 +19,19 @@ export class MockCliSdkSession {
public aborted = false;
public messages: {}[] = [];
public events: {}[] = [];
+ public title: string | undefined;
+ public name: string | undefined;
+ public readonly renameSession = async (name: string): Promise => {
+ this.title = name;
+ this.name = name;
+ this.summary = name;
+ };
+ public readonly updateSessionSummary = async (summary: string): Promise => {
+ if (!this.name) {
+ this.title = summary;
+ }
+ this.summary = summary;
+ };
public summary?: string;
constructor(public readonly sessionId: string, public readonly startTime: Date) { }
getChatContextMessages(): Promise<{}[]> { return Promise.resolve(this.messages); }
@@ -64,7 +77,11 @@ export class MockCliSdkSessionManager {
return Promise.resolve(undefined);
}
listSessions() {
- return Promise.resolve(Array.from(this.sessions.values()).map(s => ({ sessionId: s.sessionId, startTime: s.startTime, modifiedTime: s.startTime, summary: s.summary })));
+ return Promise.resolve(Array.from(this.sessions.values()).map(s => ({ sessionId: s.sessionId, startTime: s.startTime, modifiedTime: s.startTime, summary: s.summary, name: s.name })));
+ }
+ getSessionMetadata({ sessionId }: { sessionId: string }) {
+ const session = this.sessions.get(sessionId);
+ return Promise.resolve(session ? { sessionId: session.sessionId, startTime: session.startTime, modifiedTime: session.startTime, summary: session.summary, name: session.name, isRemote: false } : undefined);
}
deleteSession(id: string) { this.sessions.delete(id); return Promise.resolve(); }
closeSession(_id: string) { return Promise.resolve(); }
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/customSessionTitleServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/customSessionTitleServiceImpl.ts
index 5ae0648820de9..628ba294bf833 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/customSessionTitleServiceImpl.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/customSessionTitleServiceImpl.ts
@@ -58,11 +58,6 @@ export class CustomSessionTitleService implements ICustomSessionTitleService {
}
public async generateSessionTitle(sessionId: string, request: { prompt?: string; command?: string }, token: CancellationToken): Promise {
- const title = await this.getCustomSessionTitle(sessionId);
- if (title) {
- return title;
- }
-
return this._keyedSessionGenerator.queue(sessionId, () => this.generateSessionTitleImpl(sessionId, request, token));
}
@@ -80,7 +75,6 @@ export class CustomSessionTitleService implements ICustomSessionTitleService {
};
const title = await titleProvider.provideChatTitle(fakeContext, token);
if (title) {
- await this.setCustomSessionTitle(sessionId, title);
return title;
}
} catch (error) {
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts
index 8c7cdd799377e..ce6d0acfd45a0 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts
@@ -103,6 +103,12 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh
this.invalidateSessionCache(sessionId);
}
+ async hasCachedChanges(sessionId: string): Promise {
+ const existingRepoKey = this.sessionRepoKeys.get(sessionId);
+ const cachedChanges = existingRepoKey ? this.workspaceFolderChanges.get(existingRepoKey) : undefined;
+ return !!cachedChanges;
+ }
+
async getWorkspaceChanges(sessionId: string): Promise {
return this.workspaceChangesSequencer.queue(sessionId, async () => {
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts
index 6ff3e2b232c62..5c2f599121fdc 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts
@@ -318,7 +318,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi
}
}
- async hasWorktreeChanges(sessionId: string): Promise {
+ async hasCachedChanges(sessionId: string): Promise {
const worktreeProperties = await this.getWorktreeProperties(sessionId);
if (!worktreeProperties || typeof worktreeProperties === 'string') {
return false;
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts
index 40b5162b95280..c011cf0bd8e81 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts
@@ -31,6 +31,7 @@ import { ClaudeAgentManager } from '../claude/node/claudeCodeAgent';
import { ClaudeCodeModels, IClaudeCodeModels } from '../claude/node/claudeCodeModels';
import { ClaudeCodeSdkService, IClaudeCodeSdkService } from '../claude/node/claudeCodeSdkService';
import { ClaudeRuntimeDataService } from '../claude/node/claudeRuntimeDataService';
+import { ClaudePluginService, IClaudePluginService } from '../claude/node/claudeSkills';
import { IClaudeSessionStateService } from '../claude/common/claudeSessionStateService';
import { ClaudeSessionStateService } from '../claude/node/claudeSessionStateService';
import { ClaudeCodeSessionService, IClaudeCodeSessionService } from '../claude/node/sessionParser/claudeCodeSessionService';
@@ -148,6 +149,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
[IFolderRepositoryManager, new SyncDescriptor(ClaudeFolderRepositoryManager)],
[IChatFolderMruService, new SyncDescriptor(ClaudeCodeFolderMruService)],
[IClaudeRuntimeDataService, new SyncDescriptor(ClaudeRuntimeDataService)],
+ [IClaudePluginService, new SyncDescriptor(ClaudePluginService)],
));
const claudeAgentManager = this._register(claudeAgentInstaService.createInstance(ClaudeAgentManager));
const claudeModels = claudeAgentInstaService.invokeFunction(accessor => accessor.get(IClaudeCodeModels));
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts
index 56bdd49a90938..d73ca8676c824 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts
@@ -8,7 +8,7 @@ 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 { getGitHubRepoInfoFromContext, 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';
@@ -30,6 +30,7 @@ import { IClaudeCodeSessionService } from '../claude/node/sessionParser/claudeCo
import { IClaudeCodeSessionInfo } from '../claude/node/sessionParser/claudeSessionSchema';
import { IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCommandService';
import { IChatFolderMruService } from '../common/folderRepositoryManager';
+import { builtinSlashCommands } from '../common/builtinSlashCommands';
import { IClaudeWorkspaceFolderService } from '../common/claudeWorkspaceFolderService';
import { buildChatHistory } from './chatHistoryBuilder';
import { ClaudeSessionOptionBuilder, buildPermissionModeItems, FOLDER_OPTION_ID, isPermissionMode, PERMISSION_MODE_OPTION_ID } from './claudeSessionOptionBuilder';
@@ -38,6 +39,23 @@ import { toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder';
// Import the tool permission handlers
import '../claude/vscode-node/toolPermissionHandlers/index';
+interface SessionMetadata {
+ readonly workingDirectoryPath: string;
+ readonly repositoryPath?: string;
+ readonly branchName?: string;
+ readonly upstreamBranchName?: string;
+ readonly hasGitHubRemote?: boolean;
+ readonly incomingChanges?: number;
+ readonly outgoingChanges?: number;
+ readonly uncommittedChanges?: number;
+}
+
+function getSessionResource(sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri): vscode.Uri | undefined {
+ return sessionItemOrResource instanceof vscode.Uri
+ ? sessionItemOrResource
+ : sessionItemOrResource?.resource;
+}
+
// Import the MCP server contributors to trigger self-registration
import '../claude/vscode-node/mcpServers/index';
@@ -250,7 +268,7 @@ export class ClaudeChatSessionItemController extends Disposable {
const selectedFolderUri = getSelectedFolderUri(context.inputState);
const folderInfo = await this.getFolderInfoForSession(newSessionId, selectedFolderUri);
if (folderInfo.cwd) {
- item.metadata = { workingDirectoryPath: folderInfo.cwd };
+ item.metadata = await this._buildSessionMetadata(folderInfo.cwd);
}
this._inProgressItems.set(newSessionId, item);
@@ -293,7 +311,7 @@ export class ClaudeChatSessionItemController extends Disposable {
newItem.timing = { created: Date.now() };
// FYI, dropping any other metadata fields here...
if (item?.metadata?.workingDirectoryPath) {
- newItem.metadata = { workingDirectoryPath: item.metadata.workingDirectoryPath };
+ newItem.metadata = await this._buildSessionMetadata(item.metadata.workingDirectoryPath);
}
// Copy parent session state to the forked session
@@ -716,7 +734,7 @@ export class ClaudeChatSessionItemController extends Disposable {
};
item.iconPath = new vscode.ThemeIcon('claude');
if (session.cwd) {
- item.metadata = { workingDirectoryPath: session.cwd };
+ item.metadata = await this._buildSessionMetadata(session.cwd);
item.changes = await this._claudeWorkspaceFolderService.getWorkspaceChanges(
session.cwd,
session.gitBranch,
@@ -741,6 +759,44 @@ export class ClaudeChatSessionItemController extends Disposable {
return repositories.length > 1;
}
+ private async _buildSessionMetadata(cwd: string): Promise {
+ const repoContext = await this._gitService.getRepository(URI.file(cwd));
+ if (!repoContext) {
+ return { workingDirectoryPath: cwd };
+ }
+
+ const changes = repoContext.changes;
+ const uncommittedChanges = changes
+ ? changes.mergeChanges.length + changes.indexChanges.length + changes.workingTree.length + changes.untrackedChanges.length
+ : 0;
+
+ return {
+ workingDirectoryPath: cwd,
+ repositoryPath: repoContext.rootUri.fsPath,
+ branchName: repoContext.headBranchName,
+ upstreamBranchName: repoContext.upstreamRemote && repoContext.upstreamBranchName
+ ? `${repoContext.upstreamRemote}/${repoContext.upstreamBranchName}`
+ : undefined,
+ hasGitHubRemote: getGitHubRepoInfoFromContext(repoContext) !== undefined,
+ incomingChanges: repoContext.headIncomingChanges ?? 0,
+ outgoingChanges: repoContext.headOutgoingChanges ?? 0,
+ uncommittedChanges,
+ };
+ }
+
+ private _registerPromptCommand(commandId: string, prompt: string): void {
+ this._register(vscode.commands.registerCommand(commandId, async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
+ const resource = getSessionResource(sessionItemOrResource);
+ if (!resource) {
+ return;
+ }
+ await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.claude-code', {
+ resource,
+ prompt,
+ });
+ }));
+ }
+
private _registerCommands(): void {
this._register(vscode.commands.registerCommand('github.copilot.claude.sessions.rename', async (sessionItem?: vscode.ChatSessionItem) => {
if (!sessionItem?.resource) {
@@ -771,5 +827,27 @@ export class ClaudeChatSessionItemController extends Disposable {
}
}
}));
+
+ this._registerPromptCommand('github.copilot.claude.sessions.commit', builtinSlashCommands.commit);
+ this._registerPromptCommand('github.copilot.claude.sessions.commitAndSync', `${builtinSlashCommands.commit} and ${builtinSlashCommands.sync}`);
+ this._registerPromptCommand('github.copilot.claude.sessions.sync', builtinSlashCommands.sync);
+
+ this._register(vscode.commands.registerCommand('github.copilot.claude.sessions.initializeRepository', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
+ const resource = getSessionResource(sessionItemOrResource);
+ if (!resource) {
+ return;
+ }
+
+ const sessionId = ClaudeSessionUri.getSessionId(resource);
+ const folderInfo = await this.getFolderInfoForSession(sessionId);
+ const workspaceFolder = URI.file(folderInfo.cwd);
+
+ const repository = await this._gitService.initRepository(workspaceFolder);
+ if (!repository) {
+ return;
+ }
+
+ void this._refreshItems(CancellationToken.None);
+ }));
}
}
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts
index 1f37a2722b820..3b678188f732d 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts
@@ -131,7 +131,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
private readonly controller: vscode.ChatSessionItemController;
private readonly newSessions = new ResourceMap();
- private readonly previouslyCachedChanges = new Map();
constructor(
@ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService,
@@ -187,7 +186,10 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
const resource = SessionIdForCLI.getResource(sessionId);
const session = controller.createChatSessionItem(resource, context.request.prompt ?? context.request.command ?? '');
this.customSessionTitleService.generateSessionTitle(sessionId, context.request, CancellationToken.None)
- .then(() => {
+ .then(async title => {
+ if (title) {
+ await this.customSessionTitleService.setCustomSessionTitle(sessionId, title);
+ }
// Given we're done generating a title, refresh the contents of this session so that the new title is picked up.
if (this.controller.items.get(resource)) {
this.refreshSession({ reason: 'update', sessionId }).catch(() => { /* expected if session was deleted */ });
@@ -225,7 +227,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
}
this._register(this.sessionService.onDidDeleteSession(async (e) => {
controller.items.delete(SessionIdForCLI.getResource(e));
- this.previouslyCachedChanges.delete(e);
}));
this._register(this.sessionService.onDidChangeSession(async (e) => {
const item = await this.toChatSessionItem(e);
@@ -315,7 +316,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
if (refreshOptions.reason === 'delete') {
const uri = SessionIdForCLI.getResource(refreshOptions.sessionId);
this.controller.items.delete(uri);
- this.previouslyCachedChanges.delete(refreshOptions.sessionId);
} else if (refreshOptions.reason === 'update' && hasKey(refreshOptions, { 'sessionIds': true })) {
await Promise.allSettled(refreshOptions.sessionIds.map(async sessionId => {
const item = await this.sessionService.getSessionItem(sessionId, CancellationToken.None);
@@ -348,8 +348,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
}
item.timing = session.timing;
item.status = session.status ?? vscode.ChatSessionStatus.Completed;
- // This way, when user refreshes everything, they get the cached changes immediately.
- item.changes = this.previouslyCachedChanges.get(session.id);
// `buildChanges` runs `git diff` and is the slow leg of populating an item. Skip it on the
// eager pass and let `resolveChatSessionItem` fill it in lazily for visible items.
@@ -369,7 +367,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
}
item.changes = changes;
- this.previouslyCachedChanges.set(session.id, changes);
}
if (token.isCancellationRequested) {
@@ -417,11 +414,12 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
if (!worktreeProperties?.repositoryPath) {
return false;
}
- const [trusted, available] = await Promise.all([
+ const [trusted, hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([
vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath)),
- this.copilotCLIWorktreeManagerService.hasWorktreeChanges(sessionId)
+ this.copilotCLIWorktreeManagerService.hasCachedChanges(sessionId),
+ this._workspaceFolderService.hasCachedChanges(sessionId)
]);
- return trusted && available;
+ return trusted && (hasCachedWorktreeChanges || hasCachedWorkspaceChanges);
}
private async buildChanges(
@@ -589,7 +587,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
const folderRepo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token);
const [history, title, optionGroups] = await Promise.all([
this.getSessionHistory(copilotcliSessionId, folderRepo, token),
- this.customSessionTitleService.getCustomSessionTitle(copilotcliSessionId),
+ this.sessionService.getSessionTitle(copilotcliSessionId, token),
this._optionGroupBuilder.buildExistingSessionInputStateGroups(resource, token),
]);
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
index a92b66b894411..62c6e2bb764c6 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
@@ -170,7 +170,6 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
private readonly _onDidCommitChatSessionItem = this._register(new Emitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>());
public readonly onDidCommitChatSessionItem: Event<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }> = this._onDidCommitChatSessionItem.event;
- private readonly previouslyCachedChanges = new Map();
public resolveChatSessionItem?: (item: vscode.ChatSessionItem, token: vscode.CancellationToken) => Promise;
@@ -297,10 +296,9 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
// `buildChanges` runs `git diff` and is the slow leg of populating an item. Skip it on the
// eager pass and let `resolveChatSessionItem` fill it in lazily for visible items.
// But if computing changes is easy (cached or the like), then include them right away to avoid a second update pass.
- let changes: vscode.ChatSessionChangedFile[] | undefined = this.previouslyCachedChanges.get(session.id);
+ let changes: vscode.ChatSessionChangedFile[] | undefined;
if (!token.isCancellationRequested && (options?.includeChanges || (await this.canBuildChangesFast(session.id, worktreeProperties)))) {
changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token);
- this.previouslyCachedChanges.set(session.id, changes);
// We need to get an updated version of worktree properties here because when the
// changes are being computed, the worktree properties are also updated with the
@@ -415,11 +413,12 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
if (!worktreeProperties?.repositoryPath) {
return false;
}
- const [trusted, available] = await Promise.all([
+ const [trusted, hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([
vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath)),
- this.worktreeManager.hasWorktreeChanges(sessionId)
+ this.worktreeManager.hasCachedChanges(sessionId),
+ this.workspaceFolderService.hasCachedChanges(sessionId)
]);
- return trusted && available;
+ return trusted && (hasCachedWorktreeChanges || hasCachedWorkspaceChanges);
}
@@ -1454,7 +1453,9 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
// If user has selected a repo, then update with repo information (right icons, etc).
if (isUntitled) {
void this.lockRepoOptionForSession(context, token);
- this.customSessionTitleService.generateSessionTitle(session.object.sessionId, request, token).catch(ex => this.logService.error(ex, 'Failed to generate custom session title'));
+ this.customSessionTitleService.generateSessionTitle(session.object.sessionId, request, token)
+ .then(title => title ? this.sessionService.updateSessionSummary(session.object.sessionId, title) : undefined)
+ .catch(ex => this.logService.error(ex, 'Failed to generate custom session title'));
}
const requestsForSession = this.pendingRequestBySession.get(session.object.sessionId) ?? new Set();
requestsForSession.add(request);
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 a9246a68bb487..1dae596a6ca04 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
@@ -10,6 +10,7 @@ import type * as vscode from 'vscode';
import * as vscodeShim from 'vscode';
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';
+import { Change, Repository } from '../../../../platform/git/vscode/git';
import { MockGitService } from '../../../../platform/ignore/node/test/mockGitService';
import { ITestingServicesAccessor } from '../../../../platform/test/node/services';
import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspaceService';
@@ -18,6 +19,7 @@ import { mock } from '../../../../util/common/test/simpleMock';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
import { Emitter, Event } from '../../../../util/vs/base/common/event';
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
+import { observableValue } from '../../../../util/vs/base/common/observableInternal/observables/observableValue';
import { URI } from '../../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { ChatSessionStatus, MarkdownString, ThemeIcon } from '../../../../vscodeTypes';
@@ -34,6 +36,7 @@ import { IClaudeCodeSessionInfo } from '../../claude/node/sessionParser/claudeSe
import { IClaudeSlashCommandService } from '../../claude/vscode-node/claudeSlashCommandService';
import { FolderRepositoryMRUEntry, IChatFolderMruService } from '../../common/folderRepositoryManager';
import { IClaudeWorkspaceFolderService } from '../../common/claudeWorkspaceFolderService';
+import { builtinSlashCommands } from '../../common/builtinSlashCommands';
import { ClaudeChatSessionContentProvider, ClaudeChatSessionItemController } from '../claudeChatSessionContentProvider';
// Expose the most recently created items map so tests can inspect controller items.
@@ -47,6 +50,7 @@ let lastGetChatSessionInputState: vscode.ChatSessionControllerGetInputState | un
beforeAll(() => {
(vscodeShim as Record).commands = {
registerCommand: vi.fn().mockReturnValue({ dispose: () => { } }),
+ executeCommand: vi.fn().mockResolvedValue(undefined),
};
(vscodeShim as Record).chat = {
createChatSessionItemController: () => {
@@ -1215,6 +1219,71 @@ class FakeGitService extends mock() {
// #endregion
+// #region Test helpers
+
+function buildRepoContext(overrides: {
+ rootUri?: URI;
+ headBranchName?: string;
+ upstreamRemote?: string;
+ upstreamBranchName?: string;
+ headIncomingChanges?: number;
+ headOutgoingChanges?: number;
+ changes?: RepoContext['changes'];
+ remoteFetchUrls?: Array;
+} = {}): RepoContext {
+ return {
+ rootUri: overrides.rootUri ?? URI.file('/project'),
+ kind: 'repository',
+ isUsingVirtualFileSystem: false,
+ headIncomingChanges: overrides.headIncomingChanges ?? 0,
+ headOutgoingChanges: overrides.headOutgoingChanges ?? 0,
+ headBranchName: overrides.headBranchName ?? 'main',
+ headCommitHash: 'abc123',
+ upstreamBranchName: overrides.upstreamBranchName,
+ upstreamRemote: overrides.upstreamRemote,
+ isRebasing: false,
+ remoteFetchUrls: overrides.remoteFetchUrls ?? [],
+ remotes: [],
+ worktrees: [],
+ changes: overrides.changes,
+ headBranchNameObs: observableValue('test', overrides.headBranchName ?? 'main'),
+ headCommitHashObs: observableValue('test', 'abc123'),
+ upstreamBranchNameObs: observableValue('test', overrides.upstreamBranchName),
+ upstreamRemoteObs: observableValue('test', overrides.upstreamRemote),
+ isRebasingObs: observableValue('test', false),
+ isIgnored: () => Promise.resolve(false),
+ };
+}
+
+const MockChange = mock();
+function mockChange(): Change {
+ return new MockChange();
+}
+
+function findCommandHandler(commandId: string): (...args: unknown[]) => Promise {
+ const calls = vi.mocked(vscodeShim.commands.registerCommand).mock.calls;
+ const matchingCalls = calls.filter(c => c[0] === commandId);
+ const call = matchingCalls[matchingCalls.length - 1];
+ if (!call) {
+ throw new Error(`Command ${commandId} was not registered`);
+ }
+ return call[1];
+}
+
+function buildDiskSession(id: string, overrides: Partial = {}): IClaudeCodeSessionInfo {
+ return {
+ id,
+ label: id,
+ created: Date.now(),
+ lastRequestEnded: Date.now(),
+ folderName: 'my-project',
+ cwd: '/home/user/my-project',
+ ...overrides,
+ } as IClaudeCodeSessionInfo;
+}
+
+// #endregion
+
describe('ClaudeChatSessionItemController', () => {
const store = new DisposableStore();
let mockSessionService: IClaudeCodeSessionService;
@@ -1928,6 +1997,231 @@ describe('ClaudeChatSessionItemController', () => {
});
// #endregion
+
+ // #region Session metadata enrichment
+
+ describe('session metadata enrichment', () => {
+ it('includes enriched git metadata when repository exists', async () => {
+ const gitService = new MockGitService();
+ const repoCtx = buildRepoContext({
+ rootUri: URI.file('/home/user/my-project'),
+ headBranchName: 'feature-branch',
+ upstreamRemote: 'origin',
+ upstreamBranchName: 'feature-branch',
+ headIncomingChanges: 2,
+ headOutgoingChanges: 3,
+ remoteFetchUrls: ['https://github.com/owner/repo.git'],
+ changes: {
+ mergeChanges: [],
+ indexChanges: [mockChange(), mockChange()],
+ workingTree: [mockChange()],
+ untrackedChanges: [],
+ },
+ });
+ vi.spyOn(gitService, 'getRepository').mockResolvedValue(repoCtx);
+ controller = createController([URI.file('/project')], gitService);
+
+ const diskSession = buildDiskSession('enriched-meta');
+ vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
+
+ await controller.updateItemStatus('enriched-meta', ChatSessionStatus.InProgress, 'Prompt');
+
+ const item = getItem('enriched-meta');
+ expect(item!.metadata).toEqual({
+ workingDirectoryPath: '/home/user/my-project',
+ repositoryPath: URI.file('/home/user/my-project').fsPath,
+ branchName: 'feature-branch',
+ upstreamBranchName: 'origin/feature-branch',
+ hasGitHubRemote: true,
+ incomingChanges: 2,
+ outgoingChanges: 3,
+ uncommittedChanges: 3,
+ });
+ });
+
+ it('sets upstreamBranchName to undefined when no upstream remote', async () => {
+ const gitService = new MockGitService();
+ const repoCtx = buildRepoContext({
+ rootUri: URI.file('/home/user/my-project'),
+ headBranchName: 'local-only',
+ upstreamRemote: undefined,
+ upstreamBranchName: undefined,
+ });
+ vi.spyOn(gitService, 'getRepository').mockResolvedValue(repoCtx);
+ controller = createController([URI.file('/project')], gitService);
+
+ const diskSession = buildDiskSession('no-upstream');
+ vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
+
+ await controller.updateItemStatus('no-upstream', ChatSessionStatus.InProgress, 'Prompt');
+
+ const item = getItem('no-upstream');
+ expect(item!.metadata).toMatchObject({
+ branchName: 'local-only',
+ upstreamBranchName: undefined,
+ });
+ });
+
+ it('sums uncommittedChanges from all change categories', async () => {
+ const gitService = new MockGitService();
+ const repoCtx = buildRepoContext({
+ rootUri: URI.file('/home/user/my-project'),
+ changes: {
+ mergeChanges: [mockChange(), mockChange()],
+ indexChanges: [mockChange(), mockChange(), mockChange()],
+ workingTree: [mockChange()],
+ untrackedChanges: [mockChange(), mockChange(), mockChange(), mockChange()],
+ },
+ });
+ vi.spyOn(gitService, 'getRepository').mockResolvedValue(repoCtx);
+ controller = createController([URI.file('/project')], gitService);
+
+ const diskSession = buildDiskSession('many-changes');
+ vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
+
+ await controller.updateItemStatus('many-changes', ChatSessionStatus.InProgress, 'Prompt');
+
+ const item = getItem('many-changes');
+ expect(item!.metadata).toMatchObject({ uncommittedChanges: 10 });
+ });
+
+ it('sets uncommittedChanges to 0 when changes is undefined', async () => {
+ const gitService = new MockGitService();
+ const repoCtx = buildRepoContext({
+ rootUri: URI.file('/home/user/my-project'),
+ changes: undefined,
+ });
+ vi.spyOn(gitService, 'getRepository').mockResolvedValue(repoCtx);
+ controller = createController([URI.file('/project')], gitService);
+
+ const diskSession = buildDiskSession('no-changes');
+ vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
+
+ await controller.updateItemStatus('no-changes', ChatSessionStatus.InProgress, 'Prompt');
+
+ const item = getItem('no-changes');
+ expect(item!.metadata).toMatchObject({ uncommittedChanges: 0 });
+ });
+
+ it('sets hasGitHubRemote to false when no GitHub remote', async () => {
+ const gitService = new MockGitService();
+ const repoCtx = buildRepoContext({
+ rootUri: URI.file('/home/user/my-project'),
+ remoteFetchUrls: ['https://gitlab.com/owner/repo.git'],
+ });
+ vi.spyOn(gitService, 'getRepository').mockResolvedValue(repoCtx);
+ controller = createController([URI.file('/project')], gitService);
+
+ const diskSession = buildDiskSession('no-github');
+ vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
+
+ await controller.updateItemStatus('no-github', ChatSessionStatus.InProgress, 'Prompt');
+
+ const item = getItem('no-github');
+ expect(item!.metadata).toMatchObject({ hasGitHubRemote: false });
+ });
+ });
+
+ // #endregion
+
+ // #region Command handlers
+
+ describe('command handlers', () => {
+ it('commit command sends /commit prompt to the session', async () => {
+ createController([URI.file('/project')]);
+ const resource = ClaudeSessionUri.forSessionId('test-session');
+
+ await findCommandHandler('github.copilot.claude.sessions.commit')(resource);
+
+ expect(vscodeShim.commands.executeCommand).toHaveBeenCalledWith(
+ 'workbench.action.chat.openSessionWithPrompt.claude-code',
+ { resource, prompt: builtinSlashCommands.commit },
+ );
+ });
+
+ it('commitAndSync command sends combined /commit and /sync prompt', async () => {
+ createController([URI.file('/project')]);
+ const resource = ClaudeSessionUri.forSessionId('test-session');
+
+ await findCommandHandler('github.copilot.claude.sessions.commitAndSync')(resource);
+
+ expect(vscodeShim.commands.executeCommand).toHaveBeenCalledWith(
+ 'workbench.action.chat.openSessionWithPrompt.claude-code',
+ { resource, prompt: `${builtinSlashCommands.commit} and ${builtinSlashCommands.sync}` },
+ );
+ });
+
+ it('sync command sends /sync prompt to the session', async () => {
+ createController([URI.file('/project')]);
+ const resource = ClaudeSessionUri.forSessionId('test-session');
+
+ await findCommandHandler('github.copilot.claude.sessions.sync')(resource);
+
+ expect(vscodeShim.commands.executeCommand).toHaveBeenCalledWith(
+ 'workbench.action.chat.openSessionWithPrompt.claude-code',
+ { resource, prompt: builtinSlashCommands.sync },
+ );
+ });
+
+ it('commit command extracts resource from ChatSessionItem', async () => {
+ createController([URI.file('/project')]);
+ const resource = ClaudeSessionUri.forSessionId('test-session');
+ const sessionItem = { resource, label: 'Test' };
+
+ await findCommandHandler('github.copilot.claude.sessions.commit')(sessionItem);
+
+ expect(vscodeShim.commands.executeCommand).toHaveBeenCalledWith(
+ 'workbench.action.chat.openSessionWithPrompt.claude-code',
+ { resource, prompt: builtinSlashCommands.commit },
+ );
+ });
+
+ it('commands do not execute when resource is undefined', async () => {
+ createController([URI.file('/project')]);
+
+ await findCommandHandler('github.copilot.claude.sessions.commit')(undefined);
+ await findCommandHandler('github.copilot.claude.sessions.commitAndSync')(undefined);
+ await findCommandHandler('github.copilot.claude.sessions.sync')(undefined);
+
+ expect(vscodeShim.commands.executeCommand).not.toHaveBeenCalled();
+ });
+
+ it('initializeRepository calls gitService.initRepository with workspace folder', async () => {
+ const gitService = new MockGitService();
+ const initSpy = vi.spyOn(gitService, 'initRepository').mockResolvedValue({} as Repository);
+ controller = createController([], gitService);
+
+ const sessionId = 'init-repo-session';
+ const sessionStateService = lastControllerAccessor.get(IClaudeSessionStateService);
+ sessionStateService.setFolderInfoForSession(sessionId, {
+ cwd: '/home/user/my-project',
+ additionalDirectories: [],
+ });
+
+ const resource = ClaudeSessionUri.forSessionId(sessionId);
+ await findCommandHandler('github.copilot.claude.sessions.initializeRepository')(resource);
+
+ expect(initSpy).toHaveBeenCalledWith(URI.file('/home/user/my-project'));
+ });
+
+ it('initializeRepository does not throw when init returns undefined', async () => {
+ const gitService = new MockGitService();
+ vi.spyOn(gitService, 'initRepository').mockResolvedValue(undefined);
+ controller = createController([], gitService);
+
+ const sessionId = 'init-fail-session';
+ const sessionStateService = lastControllerAccessor.get(IClaudeSessionStateService);
+ sessionStateService.setFolderInfoForSession(sessionId, {
+ cwd: '/home/user/my-project',
+ additionalDirectories: [],
+ });
+
+ const resource = ClaudeSessionUri.forSessionId(sessionId);
+ await findCommandHandler('github.copilot.claude.sessions.initializeRepository')(resource);
+ });
+ });
+
+ // #endregion
});
function createClaudeSessionUri(id: string): URI {
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts
index 28e14e0e2f385..f2ebfb8bbc70d 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts
@@ -70,6 +70,7 @@ class TestSessionService extends mock() {
override isNewSessionId = vi.fn(() => false);
override deleteSession = vi.fn(async () => { });
override renameSession = vi.fn(async () => { });
+ override getSessionTitle = vi.fn(async () => '');
override getSession = vi.fn(async () => ({
object: {
sessionId: 'session-1',
diff --git a/extensions/copilot/src/extension/test/node/services.ts b/extensions/copilot/src/extension/test/node/services.ts
index 3feb8d83ddca8..e7afea1809f0d 100644
--- a/extensions/copilot/src/extension/test/node/services.ts
+++ b/extensions/copilot/src/extension/test/node/services.ts
@@ -58,6 +58,7 @@ import { IClaudeToolPermissionService } from '../../chatSessions/claude/common/c
import { ClaudeCodeModels, IClaudeCodeModels } from '../../chatSessions/claude/node/claudeCodeModels';
import { IClaudeCodeSdkService } from '../../chatSessions/claude/node/claudeCodeSdkService';
import { ClaudeRuntimeDataService } from '../../chatSessions/claude/node/claudeRuntimeDataService';
+import { IClaudePluginService } from '../../chatSessions/claude/node/claudeSkills';
import { IClaudeSessionStateService } from '../../chatSessions/claude/common/claudeSessionStateService';
import { ClaudeSessionStateService } from '../../chatSessions/claude/node/claudeSessionStateService';
import { MockClaudeCodeSdkService } from '../../chatSessions/claude/node/test/mockClaudeCodeSdkService';
@@ -167,9 +168,18 @@ export function createExtensionUnitTestingServices(disposables: Pick {
+ return [];
+ }
+}
+
class NullSimilarFilesContextService implements ISimilarFilesContextService {
declare readonly _serviceBrand: undefined;
diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts
index f440a451d2854..4b81d0a95cdfd 100644
--- a/extensions/copilot/src/platform/configuration/common/configurationService.ts
+++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts
@@ -621,7 +621,7 @@ export namespace ConfigKey {
export const CLIBranchSupport = defineSetting('chat.cli.branchSupport.enabled', ConfigType.Simple, false);
export const CLIIsolationOption = defineSetting('chat.cli.isolationOption.enabled', ConfigType.Simple, true);
export const CLIAutoCommitEnabled = defineSetting('chat.cli.autoCommit.enabled', ConfigType.Simple, true);
- export const CLISessionController = defineSetting('chat.cli.sessionController.enabled', ConfigType.Simple, true);
+ export const CLISessionController = defineSetting('chat.cli.sessionController.enabled', ConfigType.Simple, false);
export const CLIThinkingEffortEnabled = defineSetting('chat.cli.thinkingEffort.enabled', ConfigType.Simple, true);
export const CLIRemoteEnabled = defineSetting('chat.cli.remote.enabled', ConfigType.Simple, false);
export const CLISessionControllerForSessionsApp = defineSetting('chat.cli.sessionControllerForSessionsApp.enabled', ConfigType.Simple, false);
diff --git a/extensions/copilot/test/e2e/cli.stest.ts b/extensions/copilot/test/e2e/cli.stest.ts
index d0550535c7b6d..e4c5017f78c9b 100644
--- a/extensions/copilot/test/e2e/cli.stest.ts
+++ b/extensions/copilot/test/e2e/cli.stest.ts
@@ -280,6 +280,7 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl
async getRepositoryProperties() { return undefined; },
async handleRequestCompleted() { },
async getWorkspaceChanges() { return undefined; },
+ async hasCachedChanges() { return false; },
clearWorkspaceChanges() { return []; },
} as IChatSessionWorkspaceFolderService);
testingServiceCollection.define(IChatSessionWorktreeService, {
@@ -298,7 +299,7 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl
async handleRequestCompletedForWorktree() { },
async cleanupWorktreeOnArchive() { return { cleaned: false }; },
async recreateWorktreeOnUnarchive() { return { recreated: false }; },
- async hasWorktreeChanges() { return false; },
+ async hasCachedChanges() { return false; },
} as IChatSessionWorktreeService);
testingServiceCollection.define(IPromptVariablesService, new SyncDescriptor(NullPromptVariablesService));
testingServiceCollection.define(IChatDebugFileLoggerService, new NullChatDebugFileLoggerService());
diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts
index 8198fcf8c1c0d..bf286e095d4a6 100644
--- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts
+++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts
@@ -21,7 +21,7 @@ import { AgentSubscriptionManager, type IAgentSubscription } from '../common/sta
import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js';
import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest } from '../common/state/protocol/messages.js';
import type { ActionEnvelope, INotification, SessionAction, TerminalAction } from '../common/state/sessionActions.js';
-import { SessionSummary, ROOT_STATE_URI, StateComponents, type RootState } from '../common/state/sessionState.js';
+import { SessionSummary, SessionStatus, ROOT_STATE_URI, StateComponents, type RootState } from '../common/state/sessionState.js';
import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js';
import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js';
import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js';
@@ -301,8 +301,8 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
summary: s.title,
status: s.status,
workingDirectory: typeof s.workingDirectory === 'string' ? toAgentHostUri(URI.parse(s.workingDirectory), this._connectionAuthority) : undefined,
- isRead: s.isRead,
- isDone: s.isDone,
+ isRead: !!(s.status & SessionStatus.IsRead),
+ isArchived: !!(s.status & SessionStatus.IsArchived),
diffs: s.diffs,
}));
}
diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts
index b480a047f7909..0c05e7d237b4e 100644
--- a/src/vs/platform/agentHost/common/agentService.ts
+++ b/src/vs/platform/agentHost/common/agentService.ts
@@ -69,7 +69,7 @@ export interface IAgentSessionMetadata {
readonly model?: ModelSelection;
readonly workingDirectory?: URI;
readonly isRead?: boolean;
- readonly isDone?: boolean;
+ readonly isArchived?: boolean;
readonly diffs?: readonly FileEdit[];
}
diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version
index 40883ce5db8c2..4f86f0d986737 100644
--- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version
+++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version
@@ -1 +1 @@
-bfc35fb
+3948d65
diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts
index 20dbbf0f49883..7e773e7e78d4c 100644
--- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts
+++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts
@@ -9,7 +9,7 @@
// Generated from types/actions.ts — do not edit
// Run `npm run generate` to regenerate.
-import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionTurnStartedAction, type SessionDeltaAction, type SessionResponsePartAction, type SessionToolCallStartAction, type SessionToolCallDeltaAction, type SessionToolCallReadyAction, type SessionToolCallConfirmedAction, type SessionToolCallCompleteAction, type SessionToolCallResultConfirmedAction, type SessionToolCallContentChangedAction, type SessionTurnCompleteAction, type SessionTurnCancelledAction, type SessionErrorAction, type SessionTitleChangedAction, type SessionUsageAction, type SessionReasoningAction, type SessionModelChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientChangedAction, type SessionActiveClientToolsChangedAction, type SessionPendingMessageSetAction, type SessionPendingMessageRemovedAction, type SessionQueuedMessagesReorderedAction, type SessionInputRequestedAction, type SessionInputAnswerChangedAction, type SessionInputCompletedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionTruncatedAction, type SessionIsReadChangedAction, type SessionIsDoneChangedAction, type SessionDiffsChangedAction, type SessionConfigChangedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction } from './actions.js';
+import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionTurnStartedAction, type SessionDeltaAction, type SessionResponsePartAction, type SessionToolCallStartAction, type SessionToolCallDeltaAction, type SessionToolCallReadyAction, type SessionToolCallConfirmedAction, type SessionToolCallCompleteAction, type SessionToolCallResultConfirmedAction, type SessionToolCallContentChangedAction, type SessionTurnCompleteAction, type SessionTurnCancelledAction, type SessionErrorAction, type SessionTitleChangedAction, type SessionUsageAction, type SessionReasoningAction, type SessionModelChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientChangedAction, type SessionActiveClientToolsChangedAction, type SessionPendingMessageSetAction, type SessionPendingMessageRemovedAction, type SessionQueuedMessagesReorderedAction, type SessionInputRequestedAction, type SessionInputAnswerChangedAction, type SessionInputCompletedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionTruncatedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionDiffsChangedAction, type SessionConfigChangedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction } from './actions.js';
// ─── Root vs Session vs Terminal Action Unions ───────────────────────────────
@@ -56,7 +56,7 @@ export type SessionAction =
| SessionCustomizationToggledAction
| SessionTruncatedAction
| SessionIsReadChangedAction
- | SessionIsDoneChangedAction
+ | SessionIsArchivedChangedAction
| SessionDiffsChangedAction
| SessionConfigChangedAction
;
@@ -81,7 +81,7 @@ export type ClientSessionAction =
| SessionCustomizationToggledAction
| SessionTruncatedAction
| SessionIsReadChangedAction
- | SessionIsDoneChangedAction
+ | SessionIsArchivedChangedAction
| SessionConfigChangedAction
;
@@ -181,7 +181,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool
[ActionType.SessionCustomizationToggled]: true,
[ActionType.SessionTruncated]: true,
[ActionType.SessionIsReadChanged]: true,
- [ActionType.SessionIsDoneChanged]: true,
+ [ActionType.SessionIsArchivedChanged]: true,
[ActionType.SessionDiffsChanged]: false,
[ActionType.SessionConfigChanged]: true,
[ActionType.TerminalData]: false,
diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts
index 4eef97a3a6793..9865fc05bf52c 100644
--- a/src/vs/platform/agentHost/common/state/protocol/actions.ts
+++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts
@@ -51,7 +51,7 @@ export const enum ActionType {
SessionCustomizationToggled = 'session/customizationToggled',
SessionTruncated = 'session/truncated',
SessionIsReadChanged = 'session/isReadChanged',
- SessionIsDoneChanged = 'session/isDoneChanged',
+ SessionIsArchivedChanged = 'session/isArchivedChanged',
SessionDiffsChanged = 'session/diffsChanged',
SessionConfigChanged = 'session/configChanged',
RootTerminalsChanged = 'root/terminalsChanged',
@@ -581,21 +581,21 @@ export interface SessionIsReadChangedAction {
}
/**
- * The done state of the session changed.
+ * The archived state of the session changed.
*
- * Dispatched by a client to mark a session as done (e.g. the task is
- * complete) or to reopen it.
+ * Dispatched by a client to archive a session (e.g. the task is
+ * complete) or to unarchive it.
*
* @category Session Actions
* @version 1
* @clientDispatchable
*/
-export interface SessionIsDoneChangedAction {
- type: ActionType.SessionIsDoneChanged;
+export interface SessionIsArchivedChangedAction {
+ type: ActionType.SessionIsArchivedChanged;
/** Session URI */
session: URI;
- /** Whether the session is done */
- isDone: boolean;
+ /** Whether the session is archived */
+ isArchived: boolean;
}
/**
@@ -1142,7 +1142,7 @@ export type StateAction =
| SessionCustomizationToggledAction
| SessionTruncatedAction
| SessionIsReadChangedAction
- | SessionIsDoneChangedAction
+ | SessionIsArchivedChangedAction
| SessionDiffsChangedAction
| SessionConfigChangedAction
| TerminalDataAction
diff --git a/src/vs/platform/agentHost/common/state/protocol/reducers.ts b/src/vs/platform/agentHost/common/state/protocol/reducers.ts
index 5ac4557a75d8d..08bd0e7094f9d 100644
--- a/src/vs/platform/agentHost/common/state/protocol/reducers.ts
+++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts
@@ -56,18 +56,28 @@ function hasPendingToolCallConfirmation(state: SessionState): boolean {
);
}
-/** Derives the summary status from live session work. */
+/** Bitmask covering the mutually-exclusive activity bits (bits 0–4). */
+const STATUS_ACTIVITY_MASK = (1 << 5) - 1;
+
+/** Sets or clears a metadata flag on a status value. */
+function withStatusFlag(status: SessionStatus, flag: SessionStatus, set: boolean): SessionStatus {
+ return set ? status | flag : status & ~flag;
+}
+
+/** Derives the summary status from live session work, preserving orthogonal flags. */
function summaryStatus(state: SessionState, terminalStatus?: SessionStatus.Error): SessionStatus {
+ let activity: SessionStatus;
if (terminalStatus) {
- return terminalStatus;
- }
- if ((state.inputRequests?.length ?? 0) > 0 || hasPendingToolCallConfirmation(state)) {
- return SessionStatus.InputNeeded;
- }
- if (state.activeTurn) {
- return SessionStatus.InProgress;
+ activity = terminalStatus;
+ } else if ((state.inputRequests?.length ?? 0) > 0 || hasPendingToolCallConfirmation(state)) {
+ activity = SessionStatus.InputNeeded;
+ } else if (state.activeTurn) {
+ activity = SessionStatus.InProgress;
+ } else {
+ activity = SessionStatus.Idle;
}
- return SessionStatus.Idle;
+
+ return state.summary.status & ~STATUS_ACTIVITY_MASK | activity;
}
/**
@@ -155,7 +165,7 @@ function upsertInputRequest(state: SessionState, request: SessionInputRequest):
inputRequests.push(request);
}
const next = { ...state, inputRequests };
- return { ...next, summary: { ...next.summary, status: summaryStatus(next), modifiedAt: Date.now(), isRead: false } };
+ return { ...next, summary: { ...next.summary, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), modifiedAt: Date.now() } };
}
/**
@@ -308,7 +318,7 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?:
};
next = {
...next,
- summary: { ...next.summary, status: summaryStatus(next), modifiedAt: Date.now(), isRead: false },
+ summary: { ...next.summary, status: withStatusFlag(summaryStatus(next), SessionStatus.IsRead, false), modifiedAt: Date.now() },
};
// If this turn was auto-started from a pending message, remove it
@@ -559,13 +569,13 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?:
case ActionType.SessionIsReadChanged:
return {
...state,
- summary: { ...state.summary, isRead: action.isRead },
+ summary: { ...state.summary, status: withStatusFlag(state.summary.status, SessionStatus.IsRead, action.isRead) },
};
- case ActionType.SessionIsDoneChanged:
+ case ActionType.SessionIsArchivedChanged:
return {
...state,
- summary: { ...state.summary, isDone: action.isDone },
+ summary: { ...state.summary, status: withStatusFlag(state.summary.status, SessionStatus.IsArchived, action.isArchived) },
};
case ActionType.SessionDiffsChanged:
diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts
index 84d64a6e1b7b0..3acd55a9655df 100644
--- a/src/vs/platform/agentHost/common/state/protocol/state.ts
+++ b/src/vs/platform/agentHost/common/state/protocol/state.ts
@@ -282,10 +282,18 @@ export const enum SessionLifecycle {
* @category Session State
*/
export const enum SessionStatus {
+ /** Session is idle — no turn is active. */
Idle = 1,
+ /** Session ended with an error. */
Error = 1 << 1,
+ /** A turn is actively streaming. */
InProgress = 1 << 3,
+ /** A turn is in progress but blocked waiting for user input or tool confirmation. */
InputNeeded = (1 << 3) | (1 << 4),
+ /** The client has viewed this session since its last modification. */
+ IsRead = 1 << 5,
+ /** The session has been archived by the client. */
+ IsArchived = 1 << 6,
}
/**
@@ -378,10 +386,6 @@ export interface SessionSummary {
model?: ModelSelection;
/** The working directory URI for this session */
workingDirectory?: URI;
- /** Whether the client has viewed this session since its last modification */
- isRead?: boolean;
- /** Whether the session has been marked as done by the client */
- isDone?: boolean;
/** Files changed during this session with diff statistics */
diffs?: FileEdit[];
}
diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts
index f91870001ebbd..3487fa7532493 100644
--- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts
+++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts
@@ -58,7 +58,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: number
[ActionType.SessionCustomizationToggled]: 1,
[ActionType.SessionTruncated]: 1,
[ActionType.SessionIsReadChanged]: 1,
- [ActionType.SessionIsDoneChanged]: 1,
+ [ActionType.SessionIsArchivedChanged]: 1,
[ActionType.SessionDiffsChanged]: 1,
[ActionType.SessionConfigChanged]: 1,
[ActionType.RootTerminalsChanged]: 1,
diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts
index 469e46dff5ebe..af5dc626a7bfc 100644
--- a/src/vs/platform/agentHost/common/state/sessionActions.ts
+++ b/src/vs/platform/agentHost/common/state/sessionActions.ts
@@ -51,7 +51,7 @@ export {
type SessionInputRequestedAction,
type SessionInputCompletedAction,
type SessionIsReadChangedAction,
- type SessionIsDoneChangedAction,
+ type SessionIsArchivedChangedAction,
type SessionToolCallContentChangedAction,
type StateAction,
} from './protocol/actions.js';
@@ -92,7 +92,7 @@ import type {
SessionPendingMessageRemovedAction,
SessionQueuedMessagesReorderedAction,
SessionIsReadChangedAction,
- SessionIsDoneChangedAction,
+ SessionIsArchivedChangedAction,
} from './protocol/actions.js';
import type { ProtocolNotification } from './protocol/notifications.js';
@@ -134,7 +134,7 @@ export type IPendingMessageSetAction = SessionPendingMessageSetAction;
export type IPendingMessageRemovedAction = SessionPendingMessageRemovedAction;
export type IQueuedMessagesReorderedAction = SessionQueuedMessagesReorderedAction;
export type IIsReadChangedAction = SessionIsReadChangedAction;
-export type IIsDoneChangedAction = SessionIsDoneChangedAction;
+export type IIsArchivedChangedAction = SessionIsArchivedChangedAction;
// Notifications
export type INotification = ProtocolNotification;
diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts
index c0cbbd9f0c589..92b5fb624c762 100644
--- a/src/vs/platform/agentHost/node/agentHostStateManager.ts
+++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts
@@ -304,8 +304,6 @@ export class AgentHostStateManager extends Disposable {
if (current.project !== lastNotified.project) { changes.project = current.project; }
if (current.model !== lastNotified.model) { changes.model = current.model; }
if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; }
- if (current.isRead !== lastNotified.isRead) { changes.isRead = current.isRead; }
- if (current.isDone !== lastNotified.isDone) { changes.isDone = current.isDone; }
if (current.diffs !== lastNotified.diffs) { changes.diffs = current.diffs; }
this._lastNotifiedSummaries.set(session, current);
diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts
index 20cb762a31de9..afefce549c4c5 100644
--- a/src/vs/platform/agentHost/node/agentService.ts
+++ b/src/vs/platform/agentHost/node/agentService.ts
@@ -152,7 +152,7 @@ export class AgentService extends Disposable implements IAgentService {
return s;
}
try {
- const m = await ref.object.getMetadataObject({ customTitle: true, isRead: true, isDone: true, diffs: true });
+ const m = await ref.object.getMetadataObject({ customTitle: true, isRead: true, isArchived: true, isDone: true, diffs: true });
let updated = s;
if (m.customTitle) {
updated = { ...updated, summary: m.customTitle };
@@ -160,8 +160,10 @@ export class AgentService extends Disposable implements IAgentService {
if (m.isRead !== undefined) {
updated = { ...updated, isRead: m.isRead === 'true' };
}
- if (m.isDone !== undefined) {
- updated = { ...updated, isDone: m.isDone === 'true' };
+ if (m.isArchived !== undefined) {
+ updated = { ...updated, isArchived: m.isArchived === 'true' };
+ } else if (m.isDone !== undefined) {
+ updated = { ...updated, isArchived: m.isDone === 'true' };
}
if (m.diffs) {
try { updated = { ...updated, diffs: JSON.parse(m.diffs) }; } catch { /* ignore malformed */ }
@@ -476,7 +478,7 @@ export class AgentService extends Disposable implements IAgentService {
// Check for persisted metadata in the session database
let title = meta.summary ?? 'Session';
let isRead: boolean | undefined;
- let isDone: boolean | undefined;
+ let isArchived: boolean | undefined;
let diffs: ISessionFileDiff[] | undefined;
let persistedConfigValues: Record | undefined;
const ref = this._sessionDataService.tryOpenDatabase?.(session);
@@ -485,15 +487,17 @@ export class AgentService extends Disposable implements IAgentService {
const db = await ref;
if (db) {
try {
- const m = await db.object.getMetadataObject({ customTitle: true, isRead: true, isDone: true, diffs: true, configValues: true });
+ const m = await db.object.getMetadataObject({ customTitle: true, isRead: true, isArchived: true, isDone: true, diffs: true, configValues: true });
if (m.customTitle) {
title = m.customTitle;
}
if (m.isRead !== undefined) {
isRead = m.isRead === 'true';
}
- if (m.isDone !== undefined) {
- isDone = m.isDone === 'true';
+ if (m.isArchived !== undefined) {
+ isArchived = m.isArchived === 'true';
+ } else if (m.isDone !== undefined) {
+ isArchived = m.isDone === 'true';
}
if (m.diffs) {
try { diffs = JSON.parse(m.diffs); } catch { /* ignore malformed */ }
@@ -514,18 +518,25 @@ export class AgentService extends Disposable implements IAgentService {
}
}
+ // Encode isRead/isArchived as status bitmask flags
+ let status: SessionStatus = SessionStatus.Idle;
+ if (isRead) {
+ status |= SessionStatus.IsRead;
+ }
+ if (isArchived) {
+ status |= SessionStatus.IsArchived;
+ }
+
const summary: SessionSummary = {
resource: sessionStr,
provider: agent.id,
title,
- status: SessionStatus.Idle,
+ status,
createdAt: meta.startTime,
modifiedAt: meta.modifiedTime,
...(meta.project ? { project: { uri: meta.project.uri.toString(), displayName: meta.project.displayName } } : {}),
model: meta.model,
workingDirectory: meta.workingDirectory?.toString(),
- isRead,
- isDone,
diffs,
};
diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts
index c13039b8bdcb2..3b80dfef29d98 100644
--- a/src/vs/platform/agentHost/node/agentSideEffects.ts
+++ b/src/vs/platform/agentHost/node/agentSideEffects.ts
@@ -612,7 +612,8 @@ export class AgentSideEffects extends Disposable {
displayName: a.displayName,
}));
agent.sendMessage(URI.parse(action.session), action.userMessage.text, attachments, action.turnId).catch(err => {
- this._logService.error('[AgentSideEffects] sendMessage failed', err);
+ const errCode = (err as { code?: number })?.code;
+ this._logService.error(`[AgentSideEffects] sendMessage failed for session=${action.session}: code=${errCode}, message=${err instanceof Error ? err.message : String(err)}, type=${err?.constructor?.name}`, err);
this._stateManager.dispatchServerAction({
type: ActionType.SessionError,
session: action.session,
@@ -749,8 +750,8 @@ export class AgentSideEffects extends Disposable {
this._persistSessionFlag(action.session, 'isRead', action.isRead ? 'true' : '');
break;
}
- case ActionType.SessionIsDoneChanged: {
- this._persistSessionFlag(action.session, 'isDone', action.isDone ? 'true' : '');
+ case ActionType.SessionIsArchivedChanged: {
+ this._persistSessionFlag(action.session, 'isArchived', action.isArchived ? 'true' : '');
break;
}
case ActionType.SessionConfigChanged: {
diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts
index c5e1fe9201f78..10e06b56712fe 100644
--- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts
+++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts
@@ -685,9 +685,11 @@ export class CopilotAgent extends Disposable implements IAgent {
}
setClientTools(session: URI, clientId: string, tools: ToolDefinition[]): void {
+ const sessionId = AgentSession.id(session);
const activeClient = this._getOrCreateActiveClient(session);
+ const hasCachedEntry = this._sessions.has(sessionId);
+ this._logService.info(`[Copilot:${sessionId}] setClientTools: clientId=${clientId}, tools=[${tools.map(t => t.name).join(', ') || '(none)'}], hasCachedSdkSession=${hasCachedEntry}`);
activeClient.updateTools(clientId, tools);
- this._logService.info(`[Copilot:${AgentSession.id(session)}] Client tools updated: ${tools.map(t => t.name).join(', ') || '(none)'}`);
}
onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void {
@@ -707,12 +709,17 @@ export class CopilotAgent extends Disposable implements IAgent {
// dispose this session so it gets resumed with the updated config.
let entry = this._sessions.get(sessionId);
const activeClient = this._activeClients.get(session);
+ const hadCachedEntry = !!entry;
+ this._logService.info(`[Copilot:${sessionId}] sendMessage: cachedEntry=${hadCachedEntry}, hasActiveClient=${!!activeClient}, activeClientId=${activeClient ? '(set)' : '(none)'}`);
if (entry && activeClient && await activeClient.isOutdated(entry.appliedSnapshot)) {
- this._logService.info(`[Copilot:${sessionId}] Session config changed, refreshing session`);
+ this._logService.info(`[Copilot:${sessionId}] Session config changed (isOutdated=true), refreshing session. snapshotClientId=${entry.appliedSnapshot.clientId}`);
this._sessions.deleteAndDispose(sessionId);
entry = undefined;
}
+ if (!entry) {
+ this._logService.info(`[Copilot:${sessionId}] No cached entry${hadCachedEntry ? ' (was evicted by isOutdated)' : ''}, calling _resumeSession`);
+ }
entry ??= await this._resumeSession(sessionId);
// Emit any pending first-turn announcements (e.g. worktree
@@ -729,7 +736,14 @@ export class CopilotAgent extends Disposable implements IAgent {
this._onDidSessionProgress.fire(event);
}
- await entry.send(prompt, attachments, turnId);
+ try {
+ await entry.send(prompt, attachments, turnId);
+ } catch (err) {
+ const errCode = (err as { code?: number })?.code;
+ const errMsg = err instanceof Error ? err.message : String(err);
+ this._logService.error(`[Copilot:${sessionId}] entry.send() failed: code=${errCode}, message=${errMsg}, hadCachedEntry=${hadCachedEntry}, errorType=${err?.constructor?.name}`);
+ throw err;
+ }
});
}
@@ -946,7 +960,7 @@ export class CopilotAgent extends Disposable implements IAgent {
}
protected async _resumeSession(sessionId: string): Promise {
- this._logService.info(`[Copilot:${sessionId}] Session not in memory, resuming...`);
+ this._logService.info(`[Copilot:${sessionId}] _resumeSession called — session not in memory, resuming...`);
const client = await this._ensureClient();
const sessionUri = AgentSession.uri(this.id, sessionId);
@@ -968,20 +982,25 @@ export class CopilotAgent extends Disposable implements IAgent {
const factory: SessionWrapperFactory = async callbacks => {
const config = await sessionConfig(callbacks);
try {
+ this._logService.info(`[Copilot:${sessionId}] Calling SDK resumeSession...`);
const raw = await client.resumeSession(sessionId, {
...config,
workingDirectory: workingDirectory?.fsPath,
});
+ this._logService.info(`[Copilot:${sessionId}] SDK resumeSession succeeded`);
return new CopilotSessionWrapper(raw);
} catch (err) {
+ const errCode = (err as { code?: number })?.code;
+ const errMsg = err instanceof Error ? err.message : String(err);
+ this._logService.warn(`[Copilot:${sessionId}] SDK resumeSession failed: code=${errCode}, message=${errMsg}`);
// The SDK fails to resume sessions that have no messages.
// Fall back to creating a new session with the same ID,
// seeding model & working directory from stored metadata.
- if (!err || (err as { code?: number }).code !== -32603) {
+ if (!err || errCode !== -32603) {
throw err;
}
- this._logService.warn(`[Copilot:${sessionId}] Resume failed (session not found in SDK), recreating`);
+ this._logService.warn(`[Copilot:${sessionId}] Resume failed (code=-32603), falling back to createSession with same ID`);
const raw = await client.createSession({
...config,
sessionId,
@@ -990,6 +1009,7 @@ export class CopilotAgent extends Disposable implements IAgent {
reasoningEffort: this._getReasoningEffort(storedMetadata.model),
workingDirectory: workingDirectory?.fsPath,
});
+ this._logService.info(`[Copilot:${sessionId}] Fallback createSession succeeded`);
return new CopilotSessionWrapper(raw);
}
diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts
index 282f652575104..d01dd2de427d5 100644
--- a/src/vs/platform/agentHost/node/protocolServerHandler.ts
+++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts
@@ -201,7 +201,7 @@ export class ProtocolServerHandler extends Disposable {
disposables.add(transport.onClose(() => {
if (client && this._clients.get(client.clientId) === client) {
- this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}`);
+ this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}, subscriptions=${client.subscriptions.size}`);
this._clients.delete(client.clientId);
this._rejectPendingReverseRequests(client.clientId);
this._onDidChangeConnectionCount.fire(this._clients.size);
@@ -391,18 +391,24 @@ export class ProtocolServerHandler extends Disposable {
if (!provider) {
throw new Error(`Agent session URI has no provider scheme: ${s.session.toString()}`);
}
+ // Encode isRead/isArchived as status bitmask flags
+ let status = s.status ?? SessionStatus.Idle;
+ if (s.isRead) {
+ status |= SessionStatus.IsRead;
+ }
+ if (s.isArchived) {
+ status |= SessionStatus.IsArchived;
+ }
return {
resource: s.session.toString(),
provider,
title: s.summary ?? 'Session',
- status: s.status ?? SessionStatus.Idle,
+ status,
createdAt: s.startTime,
modifiedAt: s.modifiedTime,
...(s.project ? { project: { uri: s.project.uri.toString(), displayName: s.project.displayName } } : {}),
model: s.model,
workingDirectory: s.workingDirectory?.toString(),
- isRead: s.isRead,
- isDone: s.isDone,
diffs: s.diffs ? [...s.diffs] : undefined,
};
});
diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts
index 7fb556c1266b2..eeea898489b93 100644
--- a/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts
+++ b/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts
@@ -10,7 +10,7 @@ import { SubscribeResult } from '../../../common/state/protocol/commands.js';
import type { SessionAddedNotification, SessionRemovedNotification } from '../../../common/state/sessionActions.js';
import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js';
import type { ListSessionsResult, INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js';
-import { ResponsePartKind, type MarkdownResponsePart, type SessionState, type ToolCallResponsePart } from '../../../common/state/sessionState.js';
+import { ResponsePartKind, SessionStatus, type MarkdownResponsePart, type SessionState, type ToolCallResponsePart } from '../../../common/state/sessionState.js';
import { PRE_EXISTING_SESSION_URI } from '../mockAgent.js';
import {
createAndSubscribeSession,
@@ -130,22 +130,22 @@ suite('Protocol WebSocket — Session Lifecycle', function () {
assert.strictEqual(sessionAddedNotifs.length, 0, 'restore should not emit sessionAdded');
});
- test('isRead and isDone flags survive in listSessions after dispatch', async function () {
+ test('isRead and isArchived flags survive in listSessions after dispatch', async function () {
this.timeout(15_000);
- const sessionUri = await createAndSubscribeSession(client, 'test-read-done-flags');
+ const sessionUri = await createAndSubscribeSession(client, 'test-read-archived-flags');
- // Dispatch isDone=true
+ // Dispatch isArchived=true
client.notify('dispatchAction', {
clientSeq: 1,
action: {
- type: 'session/isDoneChanged',
+ type: 'session/isArchivedChanged',
session: sessionUri,
- isDone: true,
+ isArchived: true,
},
});
- await client.waitForNotification(n => isActionNotification(n, 'session/isDoneChanged'));
+ await client.waitForNotification(n => isActionNotification(n, 'session/isArchivedChanged'));
// Dispatch isRead=true
client.notify('dispatchAction', {
@@ -162,27 +162,27 @@ suite('Protocol WebSocket — Session Lifecycle', function () {
// Verify the flags are reflected in the subscribed session state
const snapshot = await client.call('subscribe', { resource: sessionUri });
const state = snapshot.snapshot.state as SessionState;
- assert.strictEqual(state.summary.isDone, true, 'isDone should be true in snapshot');
- assert.strictEqual(state.summary.isRead, true, 'isRead should be true in snapshot');
+ assert.ok(state.summary.status & SessionStatus.IsArchived, 'IsArchived flag should be set in snapshot');
+ assert.ok(state.summary.status & SessionStatus.IsRead, 'IsRead flag should be set in snapshot');
// Poll listSessions until the persisted flags appear (async DB write)
client.close();
const client2 = new TestProtocolClient(server.port);
await client2.connect();
- await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-read-done-flags-2' });
+ await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-read-archived-flags-2' });
let session: ListSessionsResult['items'][0] | undefined;
for (let i = 0; i < 20; i++) {
const result = await client2.call('listSessions');
session = result.items.find(s => s.resource === sessionUri);
- if (session?.isDone === true && session?.isRead === true) {
+ if (session && (session.status & SessionStatus.IsArchived) && (session.status & SessionStatus.IsRead)) {
break;
}
await timeout(100);
}
assert.ok(session, 'session should appear in listSessions');
- assert.strictEqual(session.isDone, true, 'isDone should be persisted in listSessions');
- assert.strictEqual(session.isRead, true, 'isRead should be persisted in listSessions');
+ assert.ok(session.status & SessionStatus.IsArchived, 'IsArchived should be persisted in listSessions');
+ assert.ok(session.status & SessionStatus.IsRead, 'IsRead should be persisted in listSessions');
client2.close();
});
@@ -215,13 +215,13 @@ suite('Protocol WebSocket — Session Lifecycle', function () {
for (let i = 0; i < 20; i++) {
const result = await client2.call('listSessions');
session = result.items.find(s => s.resource === sessionUri);
- if (session && session.isRead === false) {
+ if (session && !(session.status & SessionStatus.IsRead)) {
break;
}
await timeout(100);
}
assert.ok(session, 'session should appear in listSessions');
- assert.strictEqual(session.isRead, false, 'isRead=false should be explicitly persisted');
+ assert.strictEqual(session.status & SessionStatus.IsRead, 0, 'IsRead flag should not be set');
client2.close();
});
diff --git a/src/vs/platform/environment/common/environment.ts b/src/vs/platform/environment/common/environment.ts
index 8b3017f475ca9..4fc7ecfedc323 100644
--- a/src/vs/platform/environment/common/environment.ts
+++ b/src/vs/platform/environment/common/environment.ts
@@ -93,6 +93,13 @@ export interface IEnvironmentService {
// --- agent sessions workspace
agentSessionsWorkspace?: URI;
+ /**
+ * When running as the embedded Agents app, the user roaming data home of
+ * the host VS Code application (i.e. the default profile's settings/User
+ * directory). `undefined` when not running as embedded.
+ */
+ readonly hostUserRoamingDataHome?: URI;
+
// --- Policy
policyFile?: URI;
diff --git a/src/vs/platform/environment/node/environmentService.ts b/src/vs/platform/environment/node/environmentService.ts
index 1bb9d708407e0..d3daf31350a4b 100644
--- a/src/vs/platform/environment/node/environmentService.ts
+++ b/src/vs/platform/environment/node/environmentService.ts
@@ -4,6 +4,11 @@
*--------------------------------------------------------------------------------------------*/
import { homedir, tmpdir } from 'os';
+import { memoize } from '../../../base/common/decorators.js';
+import { INodeProcess } from '../../../base/common/platform.js';
+import { joinPath } from '../../../base/common/resources.js';
+import { URI } from '../../../base/common/uri.js';
+import { Schemas } from '../../../base/common/network.js';
import { NativeParsedArgs } from '../common/argv.js';
import { IDebugParams } from '../common/environment.js';
import { AbstractNativeEnvironmentService, parseDebugParams } from '../common/environmentService.js';
@@ -19,6 +24,33 @@ export class NativeEnvironmentService extends AbstractNativeEnvironmentService {
userDataDir: getUserDataPath(args, productService.nameShort)
}, productService);
}
+
+ @memoize
+ get hostUserRoamingDataHome(): URI | undefined {
+ if (!(process as INodeProcess).isEmbeddedApp) {
+ return undefined;
+ }
+ if (!this.isBuilt) {
+ return undefined;
+ }
+ const quality = this.productService.quality;
+ let hostProductName: string;
+ if (quality === 'stable') {
+ hostProductName = 'Code';
+ } else if (quality === 'insider') {
+ hostProductName = 'Code - Insiders';
+ } else if (quality === 'exploration') {
+ hostProductName = 'Code - Exploration';
+ } else {
+ return undefined;
+ }
+
+ // Honor the same env-var overrides that the host VS Code itself uses
+ // (portable mode and VSCODE_APPDATA), but intentionally skip --user-data-dir
+ // because that CLI arg belongs to the Agents app, not the host.
+ const hostUserDataPath = getUserDataPath(this.args, hostProductName);
+ return joinPath(URI.file(hostUserDataPath), 'User').with({ scheme: Schemas.vscodeUserData });
+ }
}
export function parsePtyHostDebugPort(args: NativeParsedArgs, isBuilt: boolean): IDebugParams {
diff --git a/src/vs/platform/storage/electron-main/storageMainService.ts b/src/vs/platform/storage/electron-main/storageMainService.ts
index c766ca7a79d80..855e5f50116ba 100644
--- a/src/vs/platform/storage/electron-main/storageMainService.ts
+++ b/src/vs/platform/storage/electron-main/storageMainService.ts
@@ -21,9 +21,6 @@ import { IAnyWorkspaceIdentifier } from '../../workspace/common/workspace.js';
import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js';
import { Schemas } from '../../../base/common/network.js';
import { ICrossAppIPCService } from '../../crossAppIpc/electron-main/crossAppIpcService.js';
-import { IProductService } from '../../product/common/productService.js';
-import { INodeProcess } from '../../../base/common/platform.js';
-import { getUserDataPath } from '../../environment/node/userDataPath.js';
//#region Storage Main Service (intent: make application, profile and workspace storage accessible to windows from main process)
@@ -100,7 +97,6 @@ export class StorageMainService extends Disposable implements IStorageMainServic
@IFileService private readonly fileService: IFileService,
@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,
@ICrossAppIPCService private readonly crossAppIPCService: ICrossAppIPCService,
- @IProductService private readonly productService: IProductService
) {
super();
@@ -212,14 +208,14 @@ export class StorageMainService extends Disposable implements IStorageMainServic
// from APPLICATION to APPLICATION_SHARED scope:
// In VS Code: reuse the own application storage (keys are local)
let fallbackStorage: IStorageMain = this.applicationStorage;
- if (this.environmentService.isBuilt && (process as INodeProcess).isEmbeddedApp) {
+ const hostUserRoamingDataHome = this.environmentService.hostUserRoamingDataHome;
+ if (hostUserRoamingDataHome) {
// - In the Agents App: create a storage backed by the host (VS Code)
// app's application DB so keys are found even if VS Code hasn't
// migrated them to the shared DB yet.
// We use ProfileStorageMain (not ApplicationStorageMain) to avoid
// writing telemetry state into the host app's DB — this is read-only.
- const hostUserDataPath = getUserDataPath(this.environmentService.args, this.productService.quality === 'stable' ? 'Code' : this.productService.quality === 'insider' ? 'Code - Insiders' : 'Code - Exploration');
- const hostApplicationStoragePath = join(hostUserDataPath, 'User', 'globalStorage', 'state.vscdb');
+ const hostApplicationStoragePath = join(hostUserRoamingDataHome.with({ scheme: Schemas.file }).fsPath, 'globalStorage', 'state.vscdb');
this.logService.info(`StorageMainService: creating application shared storage with host app fallback at '${hostApplicationStoragePath}'`);
fallbackStorage = this._register(new HostApplicationStorageMain(
hostApplicationStoragePath,
diff --git a/src/vs/platform/storage/test/electron-main/storageMainService.test.ts b/src/vs/platform/storage/test/electron-main/storageMainService.test.ts
index 37e68988fcb95..7c54f44d5c914 100644
--- a/src/vs/platform/storage/test/electron-main/storageMainService.test.ts
+++ b/src/vs/platform/storage/test/electron-main/storageMainService.test.ts
@@ -131,7 +131,7 @@ suite('StorageMainService', function () {
const environmentService = new NativeEnvironmentService(parseArgs(process.argv, OPTIONS), productService);
const fileService = disposables.add(new FileService(new NullLogService()));
const uriIdentityService = disposables.add(new UriIdentityService(fileService));
- const testStorageService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService())), lifecycleMainService, fileService, uriIdentityService, nullCrossAppIPCService, productService));
+ const testStorageService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService())), lifecycleMainService, fileService, uriIdentityService, nullCrossAppIPCService));
disposables.add(testStorageService.applicationStorage);
@@ -300,7 +300,7 @@ suite('StorageMainService', function () {
const environmentService = new NativeEnvironmentService(parseArgs(process.argv, OPTIONS), productService);
const fileService = disposables.add(new FileService(new NullLogService()));
const uriIdentityService = disposables.add(new UriIdentityService(fileService));
- const storageMainService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService())), new TestLifecycleMainService(), fileService, uriIdentityService, crossAppIPCService, productService));
+ const storageMainService = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(uriIdentityService), environmentService, fileService, new NullLogService())), new TestLifecycleMainService(), fileService, uriIdentityService, crossAppIPCService));
const storage = storageMainService.applicationSharedStorage;
disposables.add(storage);
@@ -336,7 +336,7 @@ suite('StorageMainService', function () {
onDidReceiveMessage: onDidReceiveMessage2.event,
};
- const storageMainService2 = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(new UriIdentityService(fileService)), environmentService, fileService, new NullLogService())), new TestLifecycleMainService(), fileService, disposables.add(new UriIdentityService(fileService)), crossAppIPCService2, productService));
+ const storageMainService2 = disposables.add(new TestStorageMainService(new NullLogService(), environmentService, disposables.add(new UserDataProfilesMainService(disposables.add(new StateService(SaveStrategy.DELAYED, environmentService, new NullLogService(), fileService)), disposables.add(new UriIdentityService(fileService)), environmentService, fileService, new NullLogService())), new TestLifecycleMainService(), fileService, disposables.add(new UriIdentityService(fileService)), crossAppIPCService2));
const storage2 = storageMainService2.applicationSharedStorage;
disposables.add(storage2);
diff --git a/src/vs/platform/userDataProfile/common/userDataProfile.ts b/src/vs/platform/userDataProfile/common/userDataProfile.ts
index e9610806691bc..b2443cded4853 100644
--- a/src/vs/platform/userDataProfile/common/userDataProfile.ts
+++ b/src/vs/platform/userDataProfile/common/userDataProfile.ts
@@ -325,7 +325,7 @@ export class UserDataProfilesService extends Disposable implements IUserDataProf
return false;
}
- private createDefaultProfile() {
+ protected createDefaultProfile() {
const defaultProfile = toUserDataProfile('__default__profile__', localize('defaultProfile', "Default"), this.environmentService.userRoamingDataHome, this.profilesCacheHome);
return { ...defaultProfile, extensionsResource: this.getDefaultProfileExtensionsLocation() ?? defaultProfile.extensionsResource, isDefault: true };
}
diff --git a/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts b/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts
index 84d81036dd189..e6510943dca88 100644
--- a/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts
+++ b/src/vs/platform/userDataProfile/electron-main/userDataProfile.ts
@@ -4,6 +4,8 @@
*--------------------------------------------------------------------------------------------*/
import { Event } from '../../../base/common/event.js';
+import { INodeProcess } from '../../../base/common/platform.js';
+import { joinPath } from '../../../base/common/resources.js';
import { INativeEnvironmentService } from '../../environment/common/environment.js';
import { IFileService } from '../../files/common/files.js';
import { refineServiceDecorator } from '../../instantiation/common/instantiation.js';
@@ -35,6 +37,23 @@ export class UserDataProfilesMainService extends UserDataProfilesService impleme
super(stateService, uriIdentityService, environmentService, fileService, logService);
}
+ protected override createDefaultProfile(): IUserDataProfile {
+ const defaultProfile = super.createDefaultProfile();
+ if (!(process as INodeProcess).isEmbeddedApp) {
+ return defaultProfile;
+ }
+ const hostUserRoamingDataHome = this.environmentService.hostUserRoamingDataHome;
+ if (!hostUserRoamingDataHome) {
+ return defaultProfile;
+ }
+ return {
+ ...defaultProfile,
+ keybindingsResource: joinPath(hostUserRoamingDataHome, 'keybindings.json'),
+ promptsHome: joinPath(hostUserRoamingDataHome, 'prompts'),
+ mcpResource: joinPath(hostUserRoamingDataHome, 'mcp.json'),
+ };
+ }
+
getAssociatedEmptyWindows(): IEmptyWorkspaceIdentifier[] {
const emptyWindows: IEmptyWorkspaceIdentifier[] = [];
for (const id of this.profilesObject.emptyWindows.keys()) {
diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts
index b49b41647c83b..a6d10ff5f50aa 100644
--- a/src/vs/sessions/browser/workbench.ts
+++ b/src/vs/sessions/browser/workbench.ts
@@ -61,7 +61,7 @@ import { IMarkdownRendererService } from '../../platform/markdown/browser/markdo
import { EditorMarkdownCodeBlockRenderer } from '../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js';
import { SyncDescriptor } from '../../platform/instantiation/common/descriptors.js';
import { TitleService } from './parts/titlebarPart.js';
-import { SessionsExperimentalSendButtonGradientSettingId, SessionsExperimentalShellGradientBackgroundSettingId } from '../common/configuration.js';
+import { SessionsExperimentalShellGradientBackgroundSettingId } from '../common/configuration.js';
import { IContextKeyService } from '../../platform/contextkey/common/contextkey.js';
import { EditorMaximizedContext } from '../common/contextkeys.js';
import {
@@ -91,7 +91,6 @@ enum LayoutClasses {
CHATBAR_HIDDEN = 'nochatbar',
STATUSBAR_HIDDEN = 'nostatusbar',
EXPERIMENTAL_SHELL_GRADIENT_BACKGROUND = 'experimental-shell-gradient-background',
- EXPERIMENTAL_SEND_BUTTON_GRADIENT = 'sessions-experimental-send-button-gradient',
FULLSCREEN = 'fullscreen',
MAXIMIZED = 'maximized'
}
@@ -447,7 +446,6 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic
// Configuration changes
this._register(configurationService.onDidChangeConfiguration(e => this.updateFontAliasing(e, configurationService)));
this._register(configurationService.onDidChangeConfiguration(e => this.updateShellGradientBackground(e, configurationService)));
- this._register(configurationService.onDidChangeConfiguration(e => this.updateSendButtonGradient(e, configurationService)));
// Font Info
if (isNative) {
@@ -542,17 +540,6 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic
);
}
- private updateSendButtonGradient(e: IConfigurationChangeEvent | undefined, configurationService: IConfigurationService): void {
- if (e && !e.affectsConfiguration(SessionsExperimentalSendButtonGradientSettingId)) {
- return;
- }
-
- this.mainContainer.classList.toggle(
- LayoutClasses.EXPERIMENTAL_SEND_BUTTON_GRADIENT,
- configurationService.getValue(SessionsExperimentalSendButtonGradientSettingId)
- );
- }
-
//#endregion
private renderWorkbench(instantiationService: IInstantiationService, notificationService: NotificationService, storageService: IStorageService, configurationService: IConfigurationService): void {
@@ -577,7 +564,6 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic
// Apply font aliasing
this.updateFontAliasing(undefined, configurationService);
this.updateShellGradientBackground(undefined, configurationService);
- this.updateSendButtonGradient(undefined, configurationService);
// Warm up font cache information before building up too many dom elements
this.restoreFontInfo(storageService, configurationService);
diff --git a/src/vs/sessions/common/configuration.ts b/src/vs/sessions/common/configuration.ts
index 920a0f37133c8..16949ea8c9c0d 100644
--- a/src/vs/sessions/common/configuration.ts
+++ b/src/vs/sessions/common/configuration.ts
@@ -4,4 +4,3 @@
*--------------------------------------------------------------------------------------------*/
export const SessionsExperimentalShellGradientBackgroundSettingId = 'sessions.experimental.shellGradientBackground';
-export const SessionsExperimentalSendButtonGradientSettingId = 'sessions.experimental.sendButtonGradient';
diff --git a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts
index 38703790de8ad..2e6e07c72cdd1 100644
--- a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts
+++ b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts
@@ -17,7 +17,7 @@ import { localize } from '../../../../nls.js';
import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js';
import { ResolveSessionConfigResult } from '../../../../platform/agentHost/common/state/protocol/commands.js';
import { NotificationType } from '../../../../platform/agentHost/common/state/protocol/notifications.js';
-import type { FileEdit, ModelSelection, RootState, SessionState, SessionSummary } from '../../../../platform/agentHost/common/state/protocol/state.js';
+import { FileEdit, ModelSelection, RootState, SessionState, SessionSummary, SessionStatus as ProtocolSessionStatus } from '../../../../platform/agentHost/common/state/protocol/state.js';
import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js';
import { StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js';
import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';
@@ -119,7 +119,7 @@ export class AgentHostSessionAdapter implements ISession {
if (metadata.isRead === false) {
this.isRead.set(false, undefined);
}
- if (metadata.isDone) {
+ if (metadata.isArchived) {
this.isArchived.set(true, undefined);
}
if (metadata.diffs && metadata.diffs.length > 0) {
@@ -188,8 +188,8 @@ export class AgentHostSessionAdapter implements ISession {
didChange = true;
}
- if (metadata.isDone !== undefined && metadata.isDone !== this.isArchived.get()) {
- this.isArchived.set(metadata.isDone, undefined);
+ if (metadata.isArchived !== undefined && metadata.isArchived !== this.isArchived.get()) {
+ this.isArchived.set(metadata.isArchived, undefined);
didChange = true;
}
@@ -666,7 +666,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
const connection = this.connection;
if (connection) {
- const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: true };
+ const action = { type: ActionType.SessionIsArchivedChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isArchived: true };
connection.dispatch(action);
}
}
@@ -680,7 +680,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
const connection = this.connection;
if (connection) {
- const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: false };
+ const action = { type: ActionType.SessionIsArchivedChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isArchived: false };
connection.dispatch(action);
}
}
@@ -1083,8 +1083,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
this._handleModelChanged(e.action.session, e.action.model);
} else if (e.action.type === ActionType.SessionIsReadChanged && isSessionAction(e.action)) {
this._handleIsReadChanged(e.action.session, e.action.isRead);
- } else if (e.action.type === ActionType.SessionIsDoneChanged && isSessionAction(e.action)) {
- this._handleIsDoneChanged(e.action.session, e.action.isDone);
+ } else if (e.action.type === ActionType.SessionIsArchivedChanged && isSessionAction(e.action)) {
+ this._handleIsArchivedChanged(e.action.session, e.action.isArchived);
} else if (e.action.type === ActionType.SessionConfigChanged && isSessionAction(e.action)) {
this._handleConfigChanged(e.action.session, e.action.config, e.action.replace === true);
} else if (e.action.type === ActionType.SessionDiffsChanged && isSessionAction(e.action)) {
@@ -1111,8 +1111,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
...(summary.project ? { project: { uri: this.mapProjectUri(URI.parse(summary.project.uri)), displayName: summary.project.displayName } } : {}),
model: summary.model,
workingDirectory: workingDir,
- isRead: summary.isRead,
- isDone: summary.isDone,
+ isRead: !!(summary.status & ProtocolSessionStatus.IsRead),
+ isArchived: !!(summary.status & ProtocolSessionStatus.IsArchived),
};
const cached = this.createAdapter(meta);
this._sessionCache.set(rawId, cached);
@@ -1161,11 +1161,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
}
}
- private _handleIsDoneChanged(session: string, isDone: boolean): void {
+ private _handleIsArchivedChanged(session: string, isArchived: boolean): void {
const rawId = AgentSession.id(session);
const cached = this._sessionCache.get(rawId);
if (cached) {
- cached.isArchived.set(isDone, undefined);
+ cached.isArchived.set(isArchived, undefined);
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] });
}
}
@@ -1194,6 +1194,18 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
cached.status.set(uiStatus, undefined);
didChange = true;
}
+
+ const isRead = !!(changes.status & ProtocolSessionStatus.IsRead);
+ if (isRead !== cached.isRead.get()) {
+ cached.isRead.set(isRead, undefined);
+ didChange = true;
+ }
+
+ const isArchived = !!(changes.status & ProtocolSessionStatus.IsArchived);
+ if (isArchived !== cached.isArchived.get()) {
+ cached.isArchived.set(isArchived, undefined);
+ didChange = true;
+ }
}
if (changes.title !== undefined && changes.title !== cached.title.get()) {
diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts
index fea320e575137..e58a1131abf3b 100644
--- a/src/vs/sessions/contrib/changes/browser/changesView.ts
+++ b/src/vs/sessions/contrib/changes/browser/changesView.ts
@@ -165,6 +165,7 @@ class ChangesButtonBarWidget extends Disposable {
private _getButtonConfiguration(action: IAction, outgoingChanges: number, reviewState: { isLoading: boolean; commentCount: number | undefined }): { showIcon: boolean; showLabel: boolean; isSecondary?: boolean; customLabel?: string; customClass?: string } | undefined {
if (
action.id === 'github.copilot.sessions.sync' ||
+ action.id === 'github.copilot.claude.sessions.sync' ||
action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR'
) {
const customLabel = outgoingChanges > 0
@@ -197,6 +198,9 @@ class ChangesButtonBarWidget extends Disposable {
action.id === 'pr.checkoutFromChat' ||
action.id === 'github.copilot.sessions.initializeRepository' ||
action.id === 'github.copilot.sessions.commit' ||
+ action.id === 'github.copilot.claude.sessions.initializeRepository' ||
+ action.id === 'github.copilot.claude.sessions.commit' ||
+ action.id === 'github.copilot.claude.sessions.commitAndSync' ||
action.id === 'agentSession.markAsDone'
) {
return { showIcon: true, showLabel: true, isSecondary: false };
diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css
index 6cbcecec56e4a..9d7c730919321 100644
--- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css
+++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css
@@ -188,9 +188,7 @@
/* Delightful gradient styling for the chat send (submit) button. The button
is filled at rest with a slowly rotating multi-color conic gradient using
the same palette as the working-state border, and emits a quick colorful
- pulse on click. Gated behind the experimental
- `sessions.experimental.sendButtonGradient` setting via the
- `.sessions-experimental-send-button-gradient` class on the workbench root. */
+ pulse on click. */
@property --chat-send-button-anim-angle {
syntax: '';
inherits: false;
@@ -268,15 +266,9 @@
cursor: default;
}
-/* Default hover feedback when the gradient experiment is off. The gradient-on
- rules below override this with the cycling color treatment. */
-.sessions-chat-send-button .monaco-button:not(.disabled):hover {
- background-color: var(--vscode-toolbar-hoverBackground);
-}
-
/* Focus indicator drawn on the wrapper so it sits cleanly around the
22x22 button surface (the inner Button widget doesn't draw its own
- focus border). Works in both gradient-on and gradient-off states. */
+ focus border). */
.sessions-chat-send-button:has(.monaco-button:not(.disabled):focus-visible) {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
@@ -305,15 +297,13 @@
}
/* Idle: fill the entire button with a slowly rotating conic gradient (no
- border ring). Gated behind the experimental `sessions.experimental.sendButtonGradient`
- setting via the `.sessions-experimental-send-button-gradient` class on
- the workbench root.
+ border ring).
Colors are darkened (60% mixed with input background) so the gradient
reads as a calm fill rather than a saturated accent, and the conic stops
are asymmetric so the fill has a clear head and tail rather than
mirroring around the mid-point. */
-.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button .monaco-button:not(.disabled) {
+.sessions-chat-send-button .monaco-button:not(.disabled) {
background: conic-gradient(from var(--chat-send-button-anim-angle) at 0% 0%,
color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, var(--vscode-input-background)) 0deg,
color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor2) 60%, var(--vscode-input-background)) 90deg,
@@ -327,13 +317,13 @@
/* Hover/focus: subtle dark overlay to match standard toolbar button hover
feedback. Uses an inset box-shadow so the rotating gradient background is
preserved underneath. */
-.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):hover) .monaco-button,
-.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):focus-visible) .monaco-button {
+.sessions-chat-send-button:has(.monaco-button:not(.disabled):hover) .monaco-button,
+.sessions-chat-send-button:has(.monaco-button:not(.disabled):focus-visible) .monaco-button {
box-shadow: inset 0 0 0 100px rgba(0, 0, 0, 0.12);
}
/* Click: outward color pulse on the wrapper. */
-.monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):active)::after {
+.sessions-chat-send-button:has(.monaco-button:not(.disabled):active)::after {
content: '';
position: absolute;
inset: -2px;
@@ -353,35 +343,35 @@
Gradient styling for the standard chat-input send button (the one rendered
inside session views by the shared ChatInputPart). Mirrors the wrapper
rules above but targets the toolbar action-item that hosts the arrow-up
- codicon. Gated by the same `.sessions-experimental-send-button-gradient` class on
- the sessions workbench root so only Sessions/Agents UI is affected.
+ codicon. Scoped to the sessions workbench root so only Sessions/Agents UI
+ is affected.
---------------------------------------------------------------------------- */
-.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) {
+.agent-sessions-workbench .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) {
position: relative;
border-radius: 5px;
}
-.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) > .action-label.codicon-arrow-up {
+.agent-sessions-workbench .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) > .action-label.codicon-arrow-up {
transition: background-color 250ms ease, color 250ms ease;
}
/* Focus indicator drawn on the action-item wrapper so it sits cleanly around
the button surface with a small offset, matching the new-session button. */
-.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:focus-visible) {
+.agent-sessions-workbench .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:focus-visible) {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
border-radius: 5px;
}
-.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) > .action-label.codicon-arrow-up:focus,
-.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) > .action-label.codicon-arrow-up:focus-visible {
+.agent-sessions-workbench .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) > .action-label.codicon-arrow-up:focus,
+.agent-sessions-workbench .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) > .action-label.codicon-arrow-up:focus-visible {
outline: none;
}
/* Idle: fill the entire action-label with a slowly rotating conic gradient.
Colors darkened (60% mixed with input background) for a calm fill, with
asymmetric conic stops. */
-.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up {
+.agent-sessions-workbench .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up {
background: conic-gradient(from var(--chat-send-button-anim-angle) at 0% 0%,
color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor1) 60%, var(--vscode-input-background)) 0deg,
color-mix(in srgb, var(--vscode-chat-inputWorkingBorderColor2) 60%, var(--vscode-input-background)) 90deg,
@@ -400,12 +390,12 @@
/* Hover/focus: subtle dark overlay to match standard toolbar button hover
feedback. Uses an inset box-shadow so the rotating gradient background is
preserved underneath. */
-.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:hover,
-.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:focus-visible {
+.agent-sessions-workbench .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:hover,
+.agent-sessions-workbench .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up:focus-visible {
box-shadow: inset 0 0 0 100px rgba(0, 0, 0, 0.12);
}
-.monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:active)::after {
+.agent-sessions-workbench .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:active)::after {
content: '';
position: absolute;
inset: -2px;
@@ -422,10 +412,10 @@
}
@media (prefers-reduced-motion: reduce) {
- .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button .monaco-button:not(.disabled),
- .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up,
- .monaco-workbench.sessions-experimental-send-button-gradient .sessions-chat-send-button:has(.monaco-button:not(.disabled):active)::after,
- .monaco-workbench.sessions-experimental-send-button-gradient .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:active)::after {
+ .sessions-chat-send-button .monaco-button:not(.disabled),
+ .agent-sessions-workbench .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled) > .action-label.codicon-arrow-up,
+ .sessions-chat-send-button:has(.monaco-button:not(.disabled):active)::after,
+ .agent-sessions-workbench .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:active)::after {
animation: none;
}
}
diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts
index 6aa6cb99ef33c..68a1b81b00ad5 100644
--- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts
+++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts
@@ -6,7 +6,7 @@
import { ConfigurationScope, Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
import { localize } from '../../../../nls.js';
import { Registry } from '../../../../platform/registry/common/platform.js';
-import { SessionsExperimentalSendButtonGradientSettingId, SessionsExperimentalShellGradientBackgroundSettingId } from '../../../common/configuration.js';
+import { SessionsExperimentalShellGradientBackgroundSettingId } from '../../../common/configuration.js';
import { ThemeSettingDefaults } from '../../../../workbench/services/themes/common/workbenchThemeService.js';
Registry.as(Extensions.Configuration).registerConfiguration({
@@ -19,13 +19,6 @@ Registry.as(Extensions.Configuration).registerConfigurat
tags: ['experimental'],
description: localize('sessions.experimental.shellGradientBackground', "Whether to enable the experimental accent-tinted shell background in the Sessions window."),
},
- [SessionsExperimentalSendButtonGradientSettingId]: {
- type: 'boolean',
- default: false,
- scope: ConfigurationScope.APPLICATION,
- tags: ['experimental'],
- description: localize('sessions.experimental.sendButtonGradient', "Whether to show a colorful animated gradient on the chat send button in the Sessions window. The button shows a slowly rotating gradient ring at rest, fills with a cycling color on hover, and emits a color pulse on click."),
- },
},
});
@@ -71,6 +64,8 @@ Registry.as(Extensions.Configuration).registerDefaultCon
'github.copilot.chat.cli.autoCommit.enabled': false,
'github.copilot.chat.cli.branchSupport.enabled': true,
'github.copilot.chat.cli.isolationOption.enabled': true,
+ 'github.copilot.chat.cli.sessionController.enabled': false,
+ 'github.copilot.chat.cli.lazyLoadSessionItem.enabled': false,
'github.copilot.chat.cli.mcp.enabled': true,
'github.copilot.chat.cli.remote.enabled': false,
'github.copilot.chat.githubMcpServer.enabled': true,
diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts
index 810b96766dfed..f7f6415e4901b 100644
--- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts
+++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts
@@ -25,7 +25,8 @@ Registry.as(ConfigurationExtensions.Configuration).regis
[CLAUDE_CODE_ENABLED_SETTING]: {
type: 'boolean',
default: false,
- tags: ['experimental', 'onExp'],
+ tags: ['experimental'],
+ experiment: { mode: 'startup' },
description: localize('sessions.chatSessions.claude.enabled', "NOTE: This is HIGHLY experimental and under active development! Whether to enable Claude agent sessions in the sessions provider."),
},
},
diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts
index ca7987600ead1..408fe16327d15 100644
--- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts
+++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts
@@ -262,7 +262,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
if (existing) {
// If the name or clientId changed, tear down and re-register
if (existing.name !== connectionInfo.name || existing.loggedConnection.clientId !== connectionInfo.clientId) {
- this._logService.info(`[RemoteAgentHost] Reconnecting contribution for ${connectionInfo.address}`);
+ this._logService.info(`[RemoteAgentHost] Reconnecting contribution for ${connectionInfo.address}: oldClientId=${existing.loggedConnection.clientId}, newClientId=${connectionInfo.clientId}, nameChanged=${existing.name !== connectionInfo.name}`);
this._connections.deleteAndDispose(connectionInfo.address);
this._setupConnection(connectionInfo);
}
diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts
index 91155f2bde17c..b26e06f4f6c51 100644
--- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts
+++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts
@@ -50,6 +50,8 @@ interface ISerializedSessionMetadata {
readonly model?: IAgentSessionMetadata['model'];
readonly workingDirectory?: string;
readonly isRead?: boolean;
+ readonly isArchived?: boolean;
+ /** @deprecated Legacy name for `isArchived`. */
readonly isDone?: boolean;
readonly project?: { readonly uri: string; readonly displayName: string };
}
@@ -63,7 +65,7 @@ function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetad
model: meta.model,
workingDirectory: meta.workingDirectory?.toString(),
isRead: meta.isRead,
- isDone: meta.isDone,
+ isArchived: meta.isArchived,
project: meta.project ? { uri: meta.project.uri.toString(), displayName: meta.project.displayName } : undefined,
};
}
@@ -78,7 +80,7 @@ function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMeta
model: raw.model,
workingDirectory: raw.workingDirectory ? URI.parse(raw.workingDirectory) : undefined,
isRead: raw.isRead,
- isDone: raw.isDone,
+ isArchived: raw.isArchived ?? raw.isDone,
project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : undefined,
};
} catch {
@@ -469,7 +471,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid
modifiedTime: adapter.updatedAt.get().getTime(),
model: adapter.modelSelection ?? base.model,
isRead: adapter.isRead.get(),
- isDone: adapter.isArchived.get(),
+ isArchived: adapter.isArchived.get(),
}));
}
if (entries.length === 0) {
diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts
index 774a30c6a347d..e32f285a8674b 100644
--- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts
+++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts
@@ -41,6 +41,7 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js';
import { HoverStyle } from '../../../../../base/browser/ui/hover/hover.js';
import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js';
import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
+import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
import { ISessionsListModelService } from './sessionsListModelService.js';
import { IAgentHostFilterService } from '../../../remoteAgentHost/common/agentHostFilter.js';
@@ -173,6 +174,7 @@ class SessionItemRenderer implements ITreeRenderer accessor.get(IMarkdownRendererService));
const hoverService = instantiationService.invokeFunction(accessor => accessor.get(IHoverService));
+ const agentSessionsService = instantiationService.invokeFunction(accessor => accessor.get(IAgentSessionsService));
const sessionRenderer = new SessionItemRenderer(
{ grouping: this.options.grouping, sorting: this.options.sorting, isPinned: s => this.isSessionPinned(s), isRead: s => this.isSessionRead(s) },
approvalModel,
@@ -724,6 +733,7 @@ export class SessionsList extends Disposable implements ISessionsList {
contextKeyService,
markdownRendererService,
hoverService,
+ agentSessionsService,
);
const showMoreRenderer = new SessionShowMoreRenderer();
diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts
index cc00fbeccd1a2..3b4c281795bd4 100644
--- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts
+++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts
@@ -11,6 +11,7 @@ import { IContextKey, IContextKeyService } from '../../../../platform/contextkey
import { ILogService } from '../../../../platform/log/common/log.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';
+import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';
import { ActiveSessionProviderIdContext, ActiveSessionTypeContext, IsActiveSessionArchivedContext, IsActiveSessionBackgroundProviderContext, IsNewChatInSessionContext, IsNewChatSessionContext } from '../../../common/contextkeys.js';
import { ActiveSessionSupportsMultiChatContext, IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../common/sessionsManagement.js';
@@ -57,6 +58,7 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen
@ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService,
@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
+ @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService,
) {
super();
@@ -380,6 +382,13 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen
if (session) {
this.logService.info(`[ActiveSessionService] Active session changed: ${session.resource.toString()}`);
+
+ // Trigger lazy resolve for expensive session properties (e.g. changes,
+ // badge). This is fire-and-forget — the resolve result flows back through
+ // the model's onDidChangeSessions → _refreshSessionCache → adapter.update()
+ // chain, updating observables reactively. Safe for providers without a
+ // resolve handler (returns undefined).
+ this.agentSessionsService.model.observeSession(session.resource);
} else {
this.logService.trace('[ActiveSessionService] Active session cleared');
}
diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts
index 9a854c3eee9e3..2a646ea78712d 100644
--- a/src/vs/sessions/sessions.common.main.ts
+++ b/src/vs/sessions/sessions.common.main.ts
@@ -247,7 +247,8 @@ import '../workbench/contrib/bulkEdit/browser/preview/bulkEdit.contribution.js';
// Rename Symbol Tracker for Inline completions.
import '../workbench/contrib/inlineCompletions/browser/renameSymbolTrackerService.js';
-
+// Search Quick Access (file picker only, not the full search contribution)
+import '../workbench/contrib/search/browser/searchQuickAccess.contribution.js';
// Sash
import '../workbench/contrib/sash/browser/sash.contribution.js';
diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts
index fa577f8d06c9d..5d831c3ca00db 100644
--- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts
+++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts
@@ -34,7 +34,7 @@ import { IAgentSkill, IChatPromptSlashCommand, ICustomAgent, IInstructionFile, I
import { isValidPromptType, PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js';
import { IChatModel } from '../../contrib/chat/common/model/chatModel.js';
import { ChatRequestAgentPart } from '../../contrib/chat/common/requestParser/chatParserTypes.js';
-import { ChatRequestParser } from '../../contrib/chat/common/requestParser/chatRequestParser.js';
+import { ChatRequestParser, IChatParserContext } from '../../contrib/chat/common/requestParser/chatRequestParser.js';
import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../../contrib/chat/browser/attachments/chatVariables.js';
import { IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatNotebookEdit, IChatProgress, IChatService, IChatTask, IChatTaskSerialized, IChatWarningMessage } from '../../contrib/chat/common/chatService/chatService.js';
import { ChatSessionOptionsMap, IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js';
@@ -45,7 +45,7 @@ import { IExtensionService } from '../../services/extensions/common/extensions.j
import { Dto } from '../../services/extensions/common/proxyIdentifier.js';
import { ExtHostChatAgentsShape2, ExtHostContext, IChatAgentInvokeResult, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, IHookDto, IInstructionDto, IPluginDto, ISkillDto, ISlashCommandDto, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js';
import { NotebookDto } from './mainThreadNotebookDto.js';
-import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js';
+import { getChatSessionType, isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js';
import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js';
import { AICustomizationManagementSection, BUILTIN_STORAGE } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js';
import { IAgentPlugin, IAgentPluginService } from '../../contrib/chat/common/plugins/agentPluginService.js';
@@ -604,7 +604,10 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA
return;
}
- const parsedRequest = this._instantiationService.createInstance(ChatRequestParser).parseChatRequestWithReferences(getDynamicVariablesForWidget(widget), getSelectedToolAndToolSetsForWidget(widget), model.getValue()).parts;
+ const context = {
+ sessionType: getChatSessionType(widget.viewModel.model.sessionResource),
+ } satisfies IChatParserContext;
+ const parsedRequest = this._instantiationService.createInstance(ChatRequestParser).parseChatRequestWithReferences(getDynamicVariablesForWidget(widget), getSelectedToolAndToolSetsForWidget(widget), model.getValue(), ChatAgentLocation.Chat, context).parts;
const agentPart = parsedRequest.find((part): part is ChatRequestAgentPart => part instanceof ChatRequestAgentPart);
const thisAgentId = this._agents.get(handle)?.id;
if (agentPart?.agent.id !== thisAgentId) {
diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts
index aaffc774fa769..09acdfa10143d 100644
--- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts
+++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts
@@ -490,6 +490,12 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes
return existing;
}
+ // Propagate a renamed item label to the open chat model so the chat editor tab
+ // and chat panel header reflect the new title.
+ if (existing && existing.label !== updated.label && this._chatService.getSession(resource)) {
+ this._chatService.setSessionTitle(resource, updated.label);
+ }
+
this._items.set(resource, updated);
this._onDidChangeChatSessionItems.fire({
addedOrUpdated: [updated],
diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts
index b35d71b7c1ed6..0c3dab6e0f944 100644
--- a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts
+++ b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts
@@ -47,6 +47,7 @@ import { IChatWidget, IChatWidgetService, isIChatViewViewContext } from '../chat
import { ctxHasEditorModification } from '../chatEditing/chatEditingEditorContextKeys.js';
import { CHAT_SETUP_ACTION_ID } from './chatActions.js';
import { PromptFileVariableKind, toPromptFileVariableEntry } from '../../common/attachments/chatVariableEntries.js';
+import { getChatSessionType } from '../../common/model/chatUri.js';
/**
* Extracts the "owner/repo" name-with-owner from a git remote URL.
@@ -492,9 +493,9 @@ export class CreateRemoteAgentJobAction {
const defaultAgent = chatAgentService.getDefaultAgent(ChatAgentLocation.Chat);
const instantiationService = accessor.get(IInstantiationService);
const requestParser = instantiationService.createInstance(ChatRequestParser);
-
+ const context = { sessionType: getChatSessionType(sessionResource) };
// Add the request to the model first
- const parsedRequest = requestParser.parseChatRequestWithReferences(getDynamicVariablesForWidget(widget), getSelectedToolAndToolSetsForWidget(widget), userPrompt, ChatAgentLocation.Chat);
+ const parsedRequest = requestParser.parseChatRequestWithReferences(getDynamicVariablesForWidget(widget), getSelectedToolAndToolSetsForWidget(widget), userPrompt, ChatAgentLocation.Chat, context);
const addedRequest = chatModel.addRequest(
parsedRequest,
{ variables: attachedContext.asArray() },
diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts
index e51c8cb0c4895..8970d3be651dd 100644
--- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts
+++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts
@@ -48,13 +48,13 @@ function getDiffRemoved(diff: ISessionFileDiff | ICompactSessionFileDiff): numbe
}
function mapSessionStatus(status: SessionStatus | undefined): ChatSessionStatus {
- if (status === SessionStatus.InputNeeded) {
+ if (status !== undefined && (status & SessionStatus.InputNeeded) === SessionStatus.InputNeeded) {
return ChatSessionStatus.NeedsInput;
}
if (status !== undefined && (status & SessionStatus.InProgress)) {
return ChatSessionStatus.InProgress;
}
- if (status === SessionStatus.Error) {
+ if (status !== undefined && (status & SessionStatus.Error)) {
return ChatSessionStatus.Failed;
}
return ChatSessionStatus.Completed;
@@ -143,16 +143,21 @@ export class AgentHostSessionListController extends Disposable implements IChatS
this._cachedSummaries.clear();
this._items = filtered.map(s => {
const rawId = AgentSession.id(s.session);
+ let status = s.status ?? SessionStatus.Idle;
+ if (s.isRead) {
+ status |= SessionStatus.IsRead;
+ }
+ if (s.isArchived) {
+ status |= SessionStatus.IsArchived;
+ }
this._cachedSummaries.set(rawId, {
resource: s.session.toString(),
provider: this._provider,
title: s.summary ?? `Session ${rawId.substring(0, 8)}`,
- status: s.status ?? SessionStatus.Idle,
+ status,
createdAt: s.startTime,
modifiedAt: s.modifiedTime,
workingDirectory: s.workingDirectory?.toString(),
- isRead: s.isRead,
- isDone: s.isDone,
});
return this._makeItem(rawId, {
title: s.summary,
diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionHoverWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionHoverWidget.ts
index 2b05a00898da6..a39d1915dc3c4 100644
--- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionHoverWidget.ts
+++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionHoverWidget.ts
@@ -11,6 +11,7 @@ import { Codicon } from '../../../../../base/common/codicons.js';
import { fromNow, getDurationString } from '../../../../../base/common/date.js';
import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js';
import { Disposable, toDisposable } from '../../../../../base/common/lifecycle.js';
+import { autorun } from '../../../../../base/common/observable.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { localize } from '../../../../../nls.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
@@ -21,6 +22,7 @@ import { ChatViewModel } from '../../common/model/chatViewModel.js';
import { IChatWidgetService } from '../chat.js';
import { ChatListWidget } from '../widget/chatListWidget.js';
import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderIcon, getAgentSessionProviderName } from './agentSessions.js';
+import { IAgentSessionsService } from './agentSessionsService.js';
import { AgentSessionStatus, getAgentChangesSummary, hasValidDiff, IAgentSession } from './agentSessionsModel.js';
import './media/agentSessionHoverWidget.css';
@@ -44,6 +46,7 @@ export class AgentSessionHoverWidget extends Disposable {
@IChatService private readonly chatService: IChatService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
+ @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService,
) {
super();
@@ -178,21 +181,37 @@ export class AgentSessionHoverWidget extends Disposable {
dom.append(detailsRow, dom.$('span', undefined, fromNow(startTime, true, true)));
}
- // Diff information
- const diff = getAgentChangesSummary(session.changes);
- if (diff && hasValidDiff(session.changes)) {
- dom.append(detailsRow, dom.$('span.separator', undefined, '•'));
- const diffContainer = dom.append(detailsRow, dom.$('.agent-session-hover-diff'));
- if (diff.files > 0) {
- dom.append(diffContainer, dom.$('span', undefined, diff.files === 1 ? localize('tooltip.file', "1 file") : localize('tooltip.files', "{0} files", diff.files)));
+ // Diff information - rendered reactively because `changes` may be lazily
+ // resolved by the provider (see IAgentSessionsModel.observeSession). We
+ // reserve a separator + container slot here and update them whenever the
+ // observed session emits a fresh value.
+ const diffSeparator = dom.append(detailsRow, dom.$('span.separator', undefined, '•'));
+ const diffContainer = dom.append(detailsRow, dom.$('.agent-session-hover-diff'));
+ diffSeparator.style.display = 'none';
+ diffContainer.style.display = 'none';
+
+ const observed = this.agentSessionsService.model.observeSession(session.resource);
+ this._register(autorun(reader => {
+ const latest = observed.read(reader) ?? session;
+ const diff = getAgentChangesSummary(latest.changes);
+ dom.clearNode(diffContainer);
+ if (diff && hasValidDiff(latest.changes)) {
+ diffSeparator.style.display = '';
+ diffContainer.style.display = '';
+ if (diff.files > 0) {
+ dom.append(diffContainer, dom.$('span', undefined, diff.files === 1 ? localize('tooltip.file', "1 file") : localize('tooltip.files', "{0} files", diff.files)));
+ }
+ if (diff.insertions > 0) {
+ dom.append(diffContainer, dom.$('span.insertions', undefined, `+${diff.insertions}`));
+ }
+ if (diff.deletions > 0) {
+ dom.append(diffContainer, dom.$('span.deletions', undefined, `-${diff.deletions}`));
+ }
+ } else {
+ diffSeparator.style.display = 'none';
+ diffContainer.style.display = 'none';
}
- if (diff.insertions > 0) {
- dom.append(diffContainer, dom.$('span.insertions', undefined, `+${diff.insertions}`));
- }
- if (diff.deletions > 0) {
- dom.append(diffContainer, dom.$('span.deletions', undefined, `-${diff.deletions}`));
- }
- }
+ }));
// Status (only show if not completed)
if (session.status !== AgentSessionStatus.Completed) {
diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts
index 4f2cadb2e28b7..1dea65a21723e 100644
--- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts
+++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts
@@ -10,15 +10,16 @@ import { ThemeIcon } from '../../../../../base/common/themables.js';
import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js';
import { getChatSessionType } from '../../common/model/chatUri.js';
import { IProductService } from '../../../../../platform/product/common/productService.js';
+import { SessionType } from '../../common/chatSessionsService.js';
export enum AgentSessionProviders {
- Local = 'local',
- Background = 'copilotcli',
- Cloud = 'copilot-cloud-agent',
- Claude = 'claude-code',
- Codex = 'openai-codex',
- Growth = 'copilot-growth',
- AgentHostCopilot = 'agent-host-copilot',
+ Local = SessionType.Local,
+ Background = SessionType.CopilotCLI,
+ Cloud = SessionType.CopilotCloud,
+ Claude = SessionType.ClaudeCode,
+ Codex = SessionType.Codex,
+ Growth = SessionType.Growth,
+ AgentHostCopilot = SessionType.AgentHostCopilot,
}
/**
diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts
index a23f358a8cf06..f28419d86b9ac 100644
--- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts
+++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts
@@ -9,9 +9,10 @@ import { Codicon } from '../../../../../base/common/codicons.js';
import { Emitter, Event } from '../../../../../base/common/event.js';
import { IMarkdownString } from '../../../../../base/common/htmlContent.js';
import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js';
-import { ResourceMap } from '../../../../../base/common/map.js';
+import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js';
import { MarshalledId } from '../../../../../base/common/marshallingIds.js';
import { safeStringify } from '../../../../../base/common/objects.js';
+import { derived, IObservable, observableSignalFromEvent } from '../../../../../base/common/observable.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { URI, UriComponents } from '../../../../../base/common/uri.js';
import { localize } from '../../../../../nls.js';
@@ -47,6 +48,19 @@ export interface IAgentSessionsModel {
readonly sessions: IAgentSession[];
getSession(resource: URI): IAgentSession | undefined;
+ /**
+ * Returns an observable that emits the latest {@link IAgentSession} for the
+ * given resource (or `undefined` if no session is currently known).
+ *
+ * The observable updates whenever the underlying session collection changes.
+ * The first call for a given resource lazily triggers
+ * {@link IChatSessionsService.resolveChatSessionItem} so consumers reading
+ * lazy properties (e.g. `changes`) see fresh values once the provider has
+ * resolved them. In-flight resolves are deduplicated by the chat sessions
+ * service.
+ */
+ observeSession(resource: URI): IObservable;
+
resolve(provider: string | string[] | undefined): Promise;
}
@@ -527,6 +541,35 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode
return this._sessions.get(resource);
}
+ private _changedSignal: IObservable | undefined;
+ private readonly _sessionObservables = new ResourceMap>();
+ private readonly _resolvedResources = new ResourceSet();
+
+ observeSession(resource: URI): IObservable {
+ let observable = this._sessionObservables.get(resource);
+ if (!observable) {
+ // Lazily trigger a resolve for this resource so consumers reading
+ // lazy properties (e.g. `changes`) get fresh data without needing
+ // to wait for a tree row to scroll into view. The chat sessions
+ // service deduplicates in-flight resolves by resource.
+ if (!this._resolvedResources.has(resource)) {
+ this._resolvedResources.add(resource);
+ const sessionType = getChatSessionType(resource);
+ this.chatSessionsService.resolveChatSessionItem(sessionType, resource, CancellationToken.None)
+ .catch(error => this.logger.logIfTrace(`observeSession: resolve failed for ${resource.toString()}: ${error instanceof Error ? error.message : String(error)}`));
+ }
+
+ this._changedSignal ??= observableSignalFromEvent('agentSessionsChanged', this.onDidChangeSessions);
+ const signal = this._changedSignal;
+ observable = derived(reader => {
+ signal.read(reader);
+ return this._sessions.get(resource);
+ });
+ this._sessionObservables.set(resource, observable);
+ }
+ return observable;
+ }
+
async resolve(provider: string | string[] | undefined): Promise {
const providers = Array.isArray(provider)
? provider
diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts
index 81cc3a4354c8a..01aa7ab5208f4 100644
--- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts
+++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts
@@ -51,6 +51,8 @@ import { defaultButtonStyles } from '../../../../../platform/theme/browser/defau
import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js';
import { BugIndicatingError } from '../../../../../base/common/errors.js';
import { compareIgnoreCase } from '../../../../../base/common/strings.js';
+import { CancellationTokenSource } from '../../../../../base/common/cancellation.js';
+import { IChatSessionsService } from '../../common/chatSessionsService.js';
export type AgentSessionListItem = IAgentSession | IAgentSessionSection | IAgentSessionShowMore | IAgentSessionShowLess;
@@ -128,6 +130,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
@IHoverService private readonly hoverService: IHoverService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
+ @IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
) {
super();
}
@@ -310,6 +313,18 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
if (this._approvalModel) {
this.renderApprovalRow(session, template);
}
+
+ // Lazily resolve item details (timing, changes, badge, etc.)
+ this.triggerResolve(session, template);
+ }
+
+ private triggerResolve(session: ITreeNode, template: IAgentSessionItemTemplate): void {
+ const cts = new CancellationTokenSource();
+ template.elementDisposable.add({ dispose() { cts.dispose(true); } });
+
+ this.chatSessionsService.resolveChatSessionItem(session.element.providerType, session.element.resource, cts.token).catch(() => {
+ // Resolve failures are non-fatal — the item continues to display with whatever data is available
+ });
}
private renderBadge(session: ITreeNode, template: IAgentSessionItemTemplate): boolean {
diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts
index 8b8e25bb3d160..3b2ef4ca300de 100644
--- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts
+++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts
@@ -166,6 +166,12 @@ class AgentSessionReadyContribution extends Disposable implements IWorkbenchCont
return;
}
+ // Trigger a lazy resolve so providers that populate `changes` on
+ // demand (see IAgentSessionsModel.observeSession) deliver fresh data.
+ // Re-evaluation happens via the onDidChangeSessions listener in the
+ // constructor.
+ this.agentSessionsService.model.observeSession(sessionResource);
+
// Check if this is a projection-capable provider
if (!AGENT_SESSION_PROJECTION_ENABLED_PROVIDERS.has(session.providerType)) {
this._clearEntriesWatcher();
diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts
index 453cc8c975684..0c326d98dca0a 100644
--- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts
+++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts
@@ -567,7 +567,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
const resource = URI.revive(chatOptions.resource);
const ref = await chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatSessionsContribution#sendPrompt');
try {
- const promptFile = await resolvePromptSlashCommand(chatOptions.prompt, promptsService, toolsService);
+ const promptFile = await resolvePromptSlashCommand(chatOptions.prompt, contribution.type, promptsService, toolsService);
if (promptFile) {
attachedContext = [promptFile, ...(attachedContext ?? [])];
}
@@ -1374,7 +1374,7 @@ async function openChatSession(accessor: ServicesAccessor, openOptions: NewChatS
}
let attachedContext = chatSendOptions.attachedContext;
- const promptFile = await resolvePromptSlashCommand(chatSendOptions.prompt, promptsService, toolsService);
+ const promptFile = await resolvePromptSlashCommand(chatSendOptions.prompt, openOptions.type, promptsService, toolsService);
if (promptFile) {
attachedContext = [promptFile, ...(attachedContext ?? [])];
}
@@ -1410,12 +1410,12 @@ function normalizeSessionOptions(options: ReadonlyChatSessionOptionsMap | Readon
/**
* Returns the variable entry for a slash command if the prompt starts with a slash command that can be resolved to a prompt file, otherwise returns undefined.
*/
-async function resolvePromptSlashCommand(prompt: string, promptsService: IPromptsService, toolsService: ILanguageModelToolsService): Promise {
+async function resolvePromptSlashCommand(prompt: string, sessionType: string, promptsService: IPromptsService, toolsService: ILanguageModelToolsService): Promise {
const slashMatch = prompt.match(slashReg);
// starts with a slash command, add the corresponding prompt file to the context if it exists
if (slashMatch) {
// need to resolve the slash command to get the prompt file
- const slashCommand = await promptsService.resolvePromptSlashCommand(slashMatch[1], CancellationToken.None);
+ const slashCommand = await promptsService.resolvePromptSlashCommand(slashMatch[1], sessionType, CancellationToken.None);
if (slashCommand) {
const parseResult = slashCommand.parsedPromptFile;
// add the prompt file to the context
diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts
index 559af6a793c39..c3da164372ac0 100644
--- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts
+++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts
@@ -16,7 +16,7 @@ import { IChatAgentService } from '../common/participants/chatAgents.js';
import { ChatContextKeys } from '../common/actions/chatContextKeys.js';
import { IChatSlashCommandService } from '../common/participants/chatSlashCommands.js';
import { IChatService } from '../common/chatService/chatService.js';
-import { IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../common/chatSessionsService.js';
+import { IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../common/chatSessionsService.js';
import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel } from '../common/constants.js';
import { ACTION_ID_NEW_CHAT } from './actions/chatActions.js';
import { ChatSubmitAction, OpenModePickerAction, OpenModelPickerAction } from './actions/chatExecuteActions.js';
@@ -29,7 +29,6 @@ import { CONFIGURE_PROMPTS_ACTION_ID } from './promptSyntax/runPromptAction.js';
import { CONFIGURE_SKILLS_ACTION_ID } from './promptSyntax/skillActions.js';
import { IChatWidgetService } from './chat.js';
import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js';
-import { Target } from '../common/promptSyntax/promptTypes.js';
import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js';
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
@@ -67,7 +66,7 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat],
- targets: [Target.VSCode]
+ sessionTypes: [SessionType.Local],
}, async () => {
await instantiationService.invokeFunction(showConfigureHooksQuickPick);
}));
@@ -88,7 +87,7 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat],
- targets: [Target.VSCode]
+ sessionTypes: [SessionType.Local],
}, async () => {
await commandService.executeCommand(ConfigureToolsAction.ID);
}));
@@ -99,7 +98,7 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat],
- targets: [Target.VSCode]
+ sessionTypes: [SessionType.Local],
}, async () => {
await commandService.executeCommand(ManagePluginsAction.ID);
}));
@@ -122,7 +121,7 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat],
- targets: [Target.VSCode]
+ sessionTypes: [SessionType.Local],
}, async () => {
await commandService.executeCommand(OpenModePickerAction.ID);
}));
@@ -133,7 +132,7 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat],
- targets: [Target.VSCode]
+ sessionTypes: [SessionType.Local],
}, async () => {
await commandService.executeCommand(CONFIGURE_SKILLS_ACTION_ID);
}));
@@ -144,7 +143,7 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat],
- targets: [Target.VSCode]
+ sessionTypes: [SessionType.Local],
}, async () => {
await commandService.executeCommand(CONFIGURE_INSTRUCTIONS_ACTION_ID);
}));
@@ -155,7 +154,7 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat],
- targets: [Target.VSCode]
+ sessionTypes: [SessionType.Local],
}, async () => {
await commandService.executeCommand(CONFIGURE_PROMPTS_ACTION_ID);
}));
@@ -180,7 +179,7 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: false,
silent: true,
locations: [ChatAgentLocation.Chat],
- targets: [Target.VSCode]
+ sessionTypes: [SessionType.Local],
}, async (prompt, _progress, _history, _location, sessionResource) => {
const title = prompt.trim();
if (title) {
@@ -202,7 +201,7 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat],
- targets: [Target.VSCode, Target.GitHubCopilot]
+ sessionTypes: [SessionType.Local, SessionType.CopilotCLI],
}, async (_prompt, _progress, _history, _location, sessionResource) => {
setPermissionLevelForSession(sessionResource, ChatPermissionLevel.AutoApprove);
}));
@@ -213,7 +212,7 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat],
- targets: [Target.VSCode, Target.GitHubCopilot]
+ sessionTypes: [SessionType.Local, SessionType.CopilotCLI],
}, async (_prompt, _progress, _history, _location, sessionResource) => {
setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Default);
}));
@@ -224,7 +223,7 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat],
- targets: [Target.VSCode, Target.GitHubCopilot]
+ sessionTypes: [SessionType.Local, SessionType.CopilotCLI],
}, async (_prompt, _progress, _history, _location, sessionResource) => {
setPermissionLevelForSession(sessionResource, ChatPermissionLevel.AutoApprove);
}));
@@ -235,7 +234,7 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat],
- targets: [Target.VSCode, Target.GitHubCopilot]
+ sessionTypes: [SessionType.Local, SessionType.CopilotCLI],
}, async (_prompt, _progress, _history, _location, sessionResource) => {
setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Default);
}));
@@ -247,7 +246,7 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat],
- targets: [Target.VSCode, Target.GitHubCopilot]
+ sessionTypes: [SessionType.Local, SessionType.CopilotCLI],
}, async (_prompt, _progress, _history, _location, sessionResource) => {
setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Autopilot);
}));
@@ -258,7 +257,7 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat],
- targets: [Target.VSCode, Target.GitHubCopilot]
+ sessionTypes: [SessionType.Local, SessionType.CopilotCLI],
}, async (_prompt, _progress, _history, _location, sessionResource) => {
setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Default);
}));
@@ -271,7 +270,7 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: true,
locations: [ChatAgentLocation.Chat],
modes: [ChatModeKind.Ask],
- targets: [Target.VSCode]
+ sessionTypes: [SessionType.Local],
}, async (prompt, progress, _history, _location, sessionResource) => {
const defaultAgent = chatAgentService.getDefaultAgent(ChatAgentLocation.Chat);
const agents = chatAgentService.getAgents();
@@ -359,7 +358,6 @@ export class ChatSessionOptionSlashCommandsContribution extends Disposable {
const store = new DisposableStore();
const seen = new Set();
- const whenClause = ChatContextKeys.chatSessionType.isEqualTo(chatSessionType);
for (const group of groups) {
for (const item of group.items) {
@@ -376,7 +374,7 @@ export class ChatSessionOptionSlashCommandsContribution extends Disposable {
continue;
}
seen.add(name);
- store.add(this.registerOne(chatSessionType, group, item, name, whenClause));
+ store.add(this.registerOne(chatSessionType, group, item, name));
}
}
@@ -391,8 +389,7 @@ export class ChatSessionOptionSlashCommandsContribution extends Disposable {
chatSessionType: string,
group: IChatSessionProviderOptionGroup,
item: IChatSessionProviderOptionItem,
- name: string,
- whenClause: ReturnType,
+ name: string
) {
return this.slashCommandService.registerSlashCommand({
command: name,
@@ -401,7 +398,7 @@ export class ChatSessionOptionSlashCommandsContribution extends Disposable {
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat],
- when: whenClause,
+ sessionTypes: [chatSessionType],
}, async (_prompt, _progress, _history, _location, sessionResource) => {
if (!sessionResource) {
return;
diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts
index ea235a6c13645..ea9da97bf924c 100644
--- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts
+++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts
@@ -84,6 +84,7 @@ import { ChatTipContentPart } from './chatContentParts/chatTipContentPart.js';
import { ChatContentMarkdownRenderer } from './chatContentMarkdownRenderer.js';
import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js';
import { IChatDebugService } from '../../common/chatDebugService.js';
+import { getChatSessionType } from '../../common/model/chatUri.js';
const $ = dom.$;
@@ -355,7 +356,8 @@ export class ChatWidget extends Disposable implements IChatWidget {
selectedAgent: this._lastSelectedAgent,
mode: this.input.currentModeKind,
attachmentCapabilities: this.attachmentCapabilities,
- forcedAgent: this._lockedAgent?.id ? this.chatAgentService.getAgent(this._lockedAgent.id) : undefined
+ forcedAgent: this._lockedAgent?.id ? this.chatAgentService.getAgent(this._lockedAgent.id) : undefined,
+ sessionType: getChatSessionType(this.viewModel.model.sessionResource)
});
this._onDidChangeParsedInput.fire();
}
@@ -892,7 +894,14 @@ export class ChatWidget extends Disposable implements IChatWidget {
}
const previous = this.parsedChatRequest;
- this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequestWithReferences(getDynamicVariablesForWidget(this), getSelectedToolAndToolSetsForWidget(this), this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind, attachmentCapabilities: this.attachmentCapabilities });
+ const context = {
+ selectedAgent: this._lastSelectedAgent,
+ mode: this.input.currentModeKind,
+ attachmentCapabilities: this.attachmentCapabilities,
+ sessionType: getChatSessionType(this.viewModel.model.sessionResource),
+ forcedAgent: this._lockedAgent?.id ? this.chatAgentService.getAgent(this._lockedAgent.id) : undefined,
+ };
+ this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequestWithReferences(getDynamicVariablesForWidget(this), getSelectedToolAndToolSetsForWidget(this), this.getInput(), this.location, context);
if (!previous || !IParsedChatRequest.equals(previous, this.parsedChatRequest)) {
this._onDidChangeParsedInput.fire();
}
@@ -2293,8 +2302,10 @@ export class ChatWidget extends Disposable implements IChatWidget {
// Track them now so tip exclusions still update for commands like /init.
this.chatTipService.recordSlashCommandUsage(agentSlashPromptPart.name);
+ const sessionType = this.viewModel ? getChatSessionType(this.viewModel.model.sessionResource) : undefined;
+
// need to resolve the slash command to get the prompt file
- const slashCommand = await this.promptsService.resolvePromptSlashCommand(agentSlashPromptPart.name, CancellationToken.None);
+ const slashCommand = await this.promptsService.resolvePromptSlashCommand(agentSlashPromptPart.name, sessionType, CancellationToken.None);
if (!slashCommand) {
return;
}
diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts
index e9d66cefdc382..b128fe75fe673 100644
--- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts
+++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts
@@ -56,7 +56,6 @@ import { isToolSet } from '../../../../common/tools/languageModelToolsService.js
import { IChatSessionsService } from '../../../../common/chatSessionsService.js';
import { ICustomizationHarnessService, getActiveHarnessSlashCommands } from '../../../../common/customizationHarnessService.js';
import { IPromptsService, matchesSessionType } from '../../../../common/promptSyntax/service/promptsService.js';
-import { Target } from '../../../../common/promptSyntax/promptTypes.js';
import { ChatSubmitAction, IChatExecuteActionContext } from '../../../actions/chatExecuteActions.js';
import { IChatWidget, IChatWidgetService } from '../../../chat.js';
import { resizeImage } from '../../../chatImageUtils.js';
@@ -99,13 +98,6 @@ class SlashCommandCompletions extends Disposable {
return null;
}
-
- let customAgentTarget: Target | undefined = undefined;
- if (widget.lockedAgentId) {
- const sessionResource = widget.viewModel.model.sessionResource;
- customAgentTarget = (sessionResource ? chatSessionsService.getCustomAgentTargetForSessionType(getChatSessionType(sessionResource)) : undefined) ?? Target.Undefined;
- }
-
const range = computeCompletionRanges(model, position, SlashCommandWord);
if (!range) {
return null;
@@ -128,6 +120,8 @@ class SlashCommandCompletions extends Disposable {
return null;
}
+ const sessionType = getChatSessionType(widget.viewModel.model.sessionResource);
+
return {
suggestions: slashCommands
.filter(c => {
@@ -140,15 +134,15 @@ class SlashCommandCompletions extends Disposable {
if (c.when && !widget.scopedContextKeyService.contextMatchesRules(c.when)) {
return false;
}
+ if (!matchesSessionType(c.sessionTypes, sessionType)) {
+ return false;
+ }
if (!widget.lockedAgentId) {
return true;
}
if (c.modes && c.modes.length && !c.modes.includes(ChatModeKind.Agent)) {
return false;
}
- if (c.targets && customAgentTarget && !c.targets.includes(customAgentTarget)) {
- return false;
- }
return true;
})
.map((c, i): CompletionItem => {
@@ -194,9 +188,12 @@ class SlashCommandCompletions extends Disposable {
return null;
}
+ const currentSessionType = getChatSessionType(widget.viewModel.model.sessionResource);
+
return {
suggestions: slashCommands
.filter(c => !c.when || widget.scopedContextKeyService.contextMatchesRules(c.when))
+ .filter(c => matchesSessionType(c.sessionTypes, currentSessionType))
.map((c, i): CompletionItem => {
const withSlash = `${chatSubcommandLeader}${c.command}`;
return {
@@ -248,7 +245,7 @@ class SlashCommandCompletions extends Disposable {
return null;
}
- const currentSessionType = widget.viewModel.model.sessionResource ? getChatSessionType(widget.viewModel.model.sessionResource) : undefined;
+ const currentSessionType = getChatSessionType(widget.viewModel.model.sessionResource);
const userInvocableCommands = promptCommands
.filter(c => c.userInvocable)
.filter(c => matchesSessionType(c.sessionTypes, currentSessionType));
diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts
index e2dcdad50f88b..4c7de690f60de 100644
--- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts
+++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts
@@ -31,6 +31,7 @@ import { TextAreaEditContextRegistry } from '../../../../../../../editor/browser
import { CancellationToken } from '../../../../../../../base/common/cancellation.js';
import { ThrottledDelayer } from '../../../../../../../base/common/async.js';
import { IEditorService } from '../../../../../../services/editor/common/editorService.js';
+import { getChatSessionType } from '../../../../common/model/chatUri.js';
const decorationDescription = 'chat';
const placeholderDecorationType = 'chat-session-detail';
@@ -301,6 +302,10 @@ class InputEditorDecorations extends Disposable {
this.widget.inputEditor.setDecorationsByType(decorationDescription, clickableSlashPromptTextDecorationType, []);
const parsedRequest = this.widget.parsedInput.parts;
+ const viewModel = this.widget.viewModel;
+ if (!viewModel) {
+ return;
+ }
const agentPart = parsedRequest.find((p): p is ChatRequestAgentPart => p instanceof ChatRequestAgentPart);
const agentSubcommandPart = parsedRequest.find((p): p is ChatRequestAgentSubcommandPart => p instanceof ChatRequestAgentSubcommandPart);
@@ -308,7 +313,7 @@ class InputEditorDecorations extends Disposable {
const slashPromptPart = parsedRequest.find((p): p is ChatRequestSlashPromptPart => p instanceof ChatRequestSlashPromptPart);
// first, fetch all async context
- const promptSlashCommand = slashPromptPart ? await this.promptsService.resolvePromptSlashCommand(slashPromptPart.name, token) : undefined;
+ const promptSlashCommand = slashPromptPart ? await this.promptsService.resolvePromptSlashCommand(slashPromptPart.name, getChatSessionType(viewModel.sessionResource), token) : undefined;
if (token.isCancellationRequested) {
// a new update came in while we were waiting
return;
@@ -341,7 +346,7 @@ class InputEditorDecorations extends Disposable {
}
if (slashCommandPart) {
- textDecorations.push({ range: slashCommandPart.editorRange });
+ textDecorations.push({ range: slashCommandPart.editorRange, hoverMessage: new MarkdownString(slashCommandPart.slashCommand.detail) });
}
if (slashPromptPart && promptSlashCommand) {
@@ -350,6 +355,10 @@ class InputEditorDecorations extends Disposable {
uri: promptSlashCommand.uri,
};
const promptHoverMessage = new MarkdownString();
+ if (promptSlashCommand.description) {
+ promptHoverMessage.appendText(promptSlashCommand.description);
+ promptHoverMessage.appendText('\n');
+ }
promptHoverMessage.appendText(localize(
'chatInput.promptSlashCommand.open',
"Click to open {0}",
@@ -372,7 +381,7 @@ class InputEditorDecorations extends Disposable {
const dynamicVariableParts = parsedRequest.filter((p): p is ChatRequestDynamicVariablePart => p instanceof ChatRequestDynamicVariablePart);
- const isEditingPreviousRequest = !!this.widget.viewModel?.editing;
+ const isEditingPreviousRequest = !!viewModel.editing;
if (isEditingPreviousRequest) {
for (const variable of dynamicVariableParts) {
varDecorations.push({ range: variable.editorRange, hoverMessage: URI.isUri(variable.data) ? new MarkdownString(this.labelService.getUriLabel(variable.data, { relative: true })) : undefined });
diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts
index 846dea7e09da4..615ad4d9893a4 100644
--- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts
+++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts
@@ -168,10 +168,24 @@ export type IChatSessionHistoryItem = {
export type IChatSessionRequestHistoryItem = Extract;
+
+/**
+ * A set of well-known session types
+ */
+export namespace SessionType {
+ export const CopilotCLI = 'copilotcli';
+ export const CopilotCloud = 'copilot-cloud-agent';
+ export const Local = 'local';
+ export const ClaudeCode = 'claude-code';
+ export const Codex = 'openai-codex';
+ export const Growth = 'copilot-growth';
+ export const AgentHostCopilot = 'agent-host-copilot';
+}
+
/**
* The session type used for local agent chat sessions.
*/
-export const localChatSessionType = 'local';
+export const localChatSessionType = SessionType.Local;
export interface IChatSession extends IDisposable {
readonly onWillDispose: Event;
diff --git a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts
index d517710e084b2..5d6d7cde20fcb 100644
--- a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts
+++ b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts
@@ -14,7 +14,6 @@ import { IChatFollowup, IChatProgress, IChatResponseProgressFileTreeData, IChatS
import { IExtensionService } from '../../../../services/extensions/common/extensions.js';
import { ChatAgentLocation, ChatModeKind } from '../constants.js';
import { URI } from '../../../../../base/common/uri.js';
-import { Target } from '../promptSyntax/promptTypes.js';
//#region slash service, commands etc
@@ -39,7 +38,7 @@ export interface IChatSlashData {
locations: ChatAgentLocation[];
modes?: ChatModeKind[];
- targets?: Target[];
+ sessionTypes?: string[];
/**
* Optional context key expression that controls visibility of this command.
diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts
index f4a97b9a8b716..70735295b98fd 100644
--- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts
+++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts
@@ -595,7 +595,7 @@ export interface IPromptsService extends IDisposable {
/**
* Gets the prompt file for a slash command.
*/
- resolvePromptSlashCommand(command: string, token: CancellationToken): Promise;
+ resolvePromptSlashCommand(command: string, sessionType: string | undefined, token: CancellationToken): Promise;
/**
* Event that is triggered when the slash command to ParsedPromptFile cache is updated.
diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts
index 07b6cc4961afb..ef662ec845fb7 100644
--- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts
+++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts
@@ -34,7 +34,7 @@ import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAU
import { PROMPT_LANGUAGE_ID, PromptFileSource, PromptsType, Target, getPromptsTypeForLanguageId } from '../promptTypes.js';
import { IWorkspaceInstructionFile, PromptFilesLocator } from '../utils/promptFilesLocator.js';
import { evaluateApplyToPattern, PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js';
-import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, isExtensionPromptPath, ILocalPromptPath, IPluginPromptPath, IPromptPath, IPromptsService, IAgentSkill, IInstructionDiscoveryInfo, IInstructionDiscoveryResult, IInstructionFile, IUserPromptPath, PromptsStorage, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IAgentInstructionFile, AgentInstructionFileType, Logger, ISlashCommandDiscoveryInfo, ISlashCommandDiscoveryResult, IAgentDiscoveryInfo, IAgentDiscoveryResult, IHookDiscoveryInfo, IResolvedChatPromptSlashCommand } from './promptsService.js';
+import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, isExtensionPromptPath, ILocalPromptPath, IPluginPromptPath, IPromptPath, IPromptsService, IAgentSkill, IInstructionDiscoveryInfo, IInstructionDiscoveryResult, IInstructionFile, IUserPromptPath, PromptsStorage, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IAgentInstructionFile, AgentInstructionFileType, Logger, ISlashCommandDiscoveryInfo, ISlashCommandDiscoveryResult, IAgentDiscoveryInfo, IAgentDiscoveryResult, IHookDiscoveryInfo, IResolvedChatPromptSlashCommand, matchesSessionType } from './promptsService.js';
import { Delayer } from '../../../../../../base/common/async.js';
import { Schemas } from '../../../../../../base/common/network.js';
import { ChatRequestHooks, parseSubagentHooksFromYaml } from '../hookSchema.js';
@@ -694,9 +694,9 @@ export class PromptsService extends Disposable implements IPromptsService {
return command.match(/^[\p{L}\d_\-\.:]+$/u) !== null;
}
- public async resolvePromptSlashCommand(name: string, token: CancellationToken): Promise {
+ public async resolvePromptSlashCommand(name: string, sessionType: string | undefined, token: CancellationToken): Promise {
const commands = await this.getPromptSlashCommands(token);
- const command = commands.find(cmd => cmd.name === name);
+ const command = commands.find(cmd => cmd.name === name && matchesSessionType(cmd.sessionTypes, sessionType));
if (command) {
return {
...command,
diff --git a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts
index 66709abd452e0..955eef0d68377 100644
--- a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts
+++ b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts
@@ -9,9 +9,10 @@ import { Range } from '../../../../../editor/common/core/range.js';
import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js';
import { IChatVariablesService, IDynamicVariable } from '../attachments/chatVariables.js';
import { ChatAgentLocation, ChatModeKind } from '../constants.js';
+import { getChatSessionType } from '../model/chatUri.js';
import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../participants/chatAgents.js';
import { IChatSlashCommandService } from '../participants/chatSlashCommands.js';
-import { IPromptsService } from '../promptSyntax/service/promptsService.js';
+import { IPromptsService, matchesSessionType } from '../promptSyntax/service/promptsService.js';
import { IToolAndToolSetEnablementMap, IToolData, IToolSet, isToolSet } from '../tools/languageModelToolsService.js';
import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestToolSetPart, IParsedChatRequest, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from './chatParserTypes.js';
@@ -26,6 +27,7 @@ export interface IChatParserContext {
/** Parse as this agent, even when it does not appear in the query text */
forcedAgent?: IChatAgentData;
attachmentCapabilities?: IChatAgentAttachmentCapabilities;
+ sessionType?: string;
}
export class ChatRequestParser {
@@ -36,9 +38,12 @@ export class ChatRequestParser {
@IPromptsService private readonly promptsService: IPromptsService,
) { }
- parseChatRequest(sessionResource: URI, message: string, location: ChatAgentLocation = ChatAgentLocation.Chat, context?: IChatParserContext): IParsedChatRequest {
+ parseChatRequest(sessionResource: URI, message: string, location: ChatAgentLocation = ChatAgentLocation.Chat, context: IChatParserContext = {}): IParsedChatRequest {
const references = this.variableService.getDynamicVariables(sessionResource); // must access this list before any async calls
const selectedToolAndToolSets = this.variableService.getSelectedToolAndToolSets(sessionResource);
+ if (!context.sessionType) {
+ context = { ...context, sessionType: getChatSessionType(sessionResource) };
+ }
return this.parseChatRequestWithReferences(references, selectedToolAndToolSets, message, location, context);
}
@@ -225,7 +230,7 @@ export class ChatRequestParser {
const capabilities = context?.attachmentCapabilities ?? usedAgent?.capabilities;
const slashCommands = this.slashCommandService.getCommands(location, context?.mode ?? ChatModeKind.Ask);
- const slashCommand = slashCommands.find(c => c.command === command);
+ const slashCommand = slashCommands.find(c => c.command === command && matchesSessionType(c.sessionTypes, context?.sessionType));
// If there is no agent, we allow any slash command.
// If there is an agent, we let
// * silent ones go through since they are only UI-facing and don't influence chat history
diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts
index b9d98566520c1..16ca999555b4d 100644
--- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts
+++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts
@@ -125,12 +125,13 @@ suite('AgentSessionsDataSource', () => {
sessions,
resolved: true,
getSession: () => undefined,
+ observeSession: () => { throw new Error('Not implemented'); },
onWillResolve: Event.None as Event,
onDidResolve: Event.None as Event,
onDidChangeSessions: Event.None,
onDidChangeSessionArchivedState: Event.None,
resolve: async () => { },
- };
+ } satisfies IAgentSessionsModel;
}
function createMockFilter(options: {
diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts
index c76355b016e57..e5c41a4623d85 100644
--- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts
+++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts
@@ -44,7 +44,7 @@ export class MockPromptsService implements IPromptsService {
getResolvedSourceFolders(_type: any): Promise { throw new Error('Not implemented'); }
isValidSlashCommandName(_command: string): boolean { return false; }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- resolvePromptSlashCommand(command: string, _token: CancellationToken): Promise { throw new Error('Not implemented'); }
+ resolvePromptSlashCommand(command: string, _sessionType: string | undefined, _token: CancellationToken): Promise { throw new Error('Not implemented'); }
get onDidChangeSlashCommands(): Event { throw new Error('Not implemented'); }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getPromptSlashCommands(_token: CancellationToken): Promise { throw new Error('Not implemented'); }
diff --git a/src/vs/workbench/contrib/search/browser/search.contribution.ts b/src/vs/workbench/contrib/search/browser/search.contribution.ts
index ec2ed02d53e18..86d572b1aba91 100644
--- a/src/vs/workbench/contrib/search/browser/search.contribution.ts
+++ b/src/vs/workbench/contrib/search/browser/search.contribution.ts
@@ -5,7 +5,6 @@
import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';
import * as platform from '../../../../base/common/platform.js';
-import { AbstractGotoLineQuickAccessProvider } from '../../../../editor/contrib/quickAccess/browser/gotoLineQuickAccess.js';
import * as nls from '../../../../nls.js';
import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
@@ -14,16 +13,12 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta
import { Extensions as QuickAccessExtensions, IQuickAccessRegistry } from '../../../../platform/quickinput/common/quickAccess.js';
import { Registry } from '../../../../platform/registry/common/platform.js';
import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js';
-import { defaultQuickAccessContextKeyValue } from '../../../browser/quickaccess.js';
import { Extensions as ViewExtensions, IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainerLocation } from '../../../common/views.js';
-import { GotoSymbolQuickAccessProvider } from '../../codeEditor/browser/quickaccess/gotoSymbolQuickAccess.js';
-import { AnythingQuickAccessProvider } from './anythingQuickAccess.js';
import { registerContributions as replaceContributions } from './replaceContributions.js';
import { registerContributions as notebookSearchContributions } from './notebookSearch/notebookSearchContributions.js';
import { searchViewIcon } from './searchIcons.js';
import { SearchView } from './searchView.js';
import { registerContributions as searchWidgetContributions } from './searchWidget.js';
-import { SymbolsQuickAccessProvider } from './symbolsQuickAccess.js';
import { ISearchHistoryService, SearchHistoryService } from '../common/searchHistoryService.js';
import { SearchViewModelWorkbenchService } from './searchTreeModel/searchModel.js';
import { ISearchViewModelWorkbenchService } from './searchTreeModel/searchViewModelWorkbenchService.js';
@@ -38,9 +33,9 @@ import './searchActionsCopy.js';
import './searchActionsFind.js';
import './searchActionsNav.js';
import './searchActionsRemoveReplace.js';
-import './searchActionsSymbol.js';
import './searchActionsTopBar.js';
import './searchActionsTextQuickAccess.js';
+import './searchQuickAccess.contribution.js';
import { TEXT_SEARCH_QUICK_ACCESS_PREFIX, TextSearchQuickAccess } from './quickTextSearch/textSearchQuickAccess.js';
import { Extensions, IConfigurationMigrationRegistry } from '../../../common/configuration.js';
import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
@@ -94,26 +89,6 @@ Registry.as(ViewExtensions.ViewsRegistry).registerViews([viewDes
// Register Quick Access Handler
const quickAccessRegistry = Registry.as(QuickAccessExtensions.Quickaccess);
-quickAccessRegistry.registerQuickAccessProvider({
- ctor: AnythingQuickAccessProvider,
- prefix: AnythingQuickAccessProvider.PREFIX,
- placeholder: nls.localize('anythingQuickAccessPlaceholder', "Search files by name (append {0} to go to line or {1} to go to symbol)", AbstractGotoLineQuickAccessProvider.GO_TO_LINE_PREFIX, GotoSymbolQuickAccessProvider.PREFIX),
- contextKey: defaultQuickAccessContextKeyValue,
- helpEntries: [{
- description: nls.localize('anythingQuickAccess', "Go to File"),
- commandId: 'workbench.action.quickOpen',
- commandCenterOrder: 10
- }]
-});
-
-quickAccessRegistry.registerQuickAccessProvider({
- ctor: SymbolsQuickAccessProvider,
- prefix: SymbolsQuickAccessProvider.PREFIX,
- placeholder: nls.localize('symbolsQuickAccessPlaceholder', "Type the name of a symbol to open."),
- contextKey: 'inWorkspaceSymbolsPicker',
- helpEntries: [{ description: nls.localize('symbolsQuickAccess', "Go to Symbol in Workspace"), commandId: Constants.SearchCommandIds.ShowAllSymbolsActionId }]
-});
-
quickAccessRegistry.registerQuickAccessProvider({
ctor: TextSearchQuickAccess,
prefix: TEXT_SEARCH_QUICK_ACCESS_PREFIX,
diff --git a/src/vs/workbench/contrib/search/browser/searchQuickAccess.contribution.ts b/src/vs/workbench/contrib/search/browser/searchQuickAccess.contribution.ts
new file mode 100644
index 0000000000000..779cd96511792
--- /dev/null
+++ b/src/vs/workbench/contrib/search/browser/searchQuickAccess.contribution.ts
@@ -0,0 +1,36 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as nls from '../../../../nls.js';
+import { AbstractGotoLineQuickAccessProvider } from '../../../../editor/contrib/quickAccess/browser/gotoLineQuickAccess.js';
+import { Extensions as QuickAccessExtensions, IQuickAccessRegistry } from '../../../../platform/quickinput/common/quickAccess.js';
+import { Registry } from '../../../../platform/registry/common/platform.js';
+import { defaultQuickAccessContextKeyValue } from '../../../browser/quickaccess.js';
+import { GotoSymbolQuickAccessProvider } from '../../codeEditor/browser/quickaccess/gotoSymbolQuickAccess.js';
+import { AnythingQuickAccessProvider } from './anythingQuickAccess.js';
+import { SymbolsQuickAccessProvider } from './symbolsQuickAccess.js';
+import './searchActionsSymbol.js';
+
+const quickAccessRegistry = Registry.as(QuickAccessExtensions.Quickaccess);
+
+quickAccessRegistry.registerQuickAccessProvider({
+ ctor: AnythingQuickAccessProvider,
+ prefix: AnythingQuickAccessProvider.PREFIX,
+ placeholder: nls.localize('anythingQuickAccessPlaceholder', "Search files by name (append {0} to go to line or {1} to go to symbol)", AbstractGotoLineQuickAccessProvider.GO_TO_LINE_PREFIX, GotoSymbolQuickAccessProvider.PREFIX),
+ contextKey: defaultQuickAccessContextKeyValue,
+ helpEntries: [{
+ description: nls.localize('anythingQuickAccess', "Go to File"),
+ commandId: 'workbench.action.quickOpen',
+ commandCenterOrder: 10
+ }]
+});
+
+quickAccessRegistry.registerQuickAccessProvider({
+ ctor: SymbolsQuickAccessProvider,
+ prefix: SymbolsQuickAccessProvider.PREFIX,
+ placeholder: nls.localize('symbolsQuickAccessPlaceholder', "Type the name of a symbol to open."),
+ contextKey: 'inWorkspaceSymbolsPicker',
+ helpEntries: [{ description: nls.localize('symbolsQuickAccess', "Go to Symbol in Workspace"), commandId: 'workbench.action.showAllSymbols' }]
+});
diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts
index 9f92c6cec0bd7..9cf13ba0e89c0 100644
--- a/test/sanity/src/context.ts
+++ b/test/sanity/src/context.ts
@@ -1003,9 +1003,10 @@ export class TestContext {
let agentsAppName: string;
switch (this.options.quality) {
case 'stable':
- appName = 'Visual Studio Code.app';
- agentsAppName = 'Visual Studio Code Agents.app';
- break;
+ // Agents app is not included in stable yet.
+ // appName = 'Visual Studio Code.app';
+ // agentsAppName = 'Visual Studio Code Agents.app';
+ return;
case 'insider':
appName = 'Visual Studio Code - Insiders.app';
agentsAppName = 'Visual Studio Code Agents - Insiders.app';
@@ -1022,8 +1023,9 @@ export class TestContext {
let exeName: string;
switch (this.options.quality) {
case 'stable':
- exeName = 'Agents.exe';
- break;
+ // Agents app is not included in stable yet.
+ // exeName = 'Agents.exe';
+ return;
case 'insider':
exeName = 'Agents - Insiders.exe';
break;
diff --git a/test/sanity/src/desktop.test.ts b/test/sanity/src/desktop.test.ts
index 6b76e4737ef1d..ff6de9bda20b7 100644
--- a/test/sanity/src/desktop.test.ts
+++ b/test/sanity/src/desktop.test.ts
@@ -91,7 +91,6 @@ export function setup(context: TestContext) {
const entryPoint = context.getDesktopEntryPoint(dir);
const dataDir = context.createPortableDataDir(dir);
await testDesktopApp(entryPoint, dataDir);
- await testAgentsApp(entryPoint, dataDir);
}
});
@@ -102,7 +101,6 @@ export function setup(context: TestContext) {
const entryPoint = context.getDesktopEntryPoint(dir);
const dataDir = context.createPortableDataDir(dir);
await testDesktopApp(entryPoint, dataDir);
- await testAgentsApp(entryPoint, dataDir);
}
});
@@ -111,7 +109,6 @@ export function setup(context: TestContext) {
if (!context.options.downloadOnly) {
const entryPoint = await context.installDeb(packagePath);
await testDesktopApp(entryPoint);
- await testAgentsApp(entryPoint);
await context.uninstallDeb();
}
});
@@ -121,7 +118,6 @@ export function setup(context: TestContext) {
if (!context.options.downloadOnly) {
const entryPoint = await context.installDeb(packagePath);
await testDesktopApp(entryPoint);
- await testAgentsApp(entryPoint);
await context.uninstallDeb();
}
});
@@ -131,7 +127,6 @@ export function setup(context: TestContext) {
if (!context.options.downloadOnly) {
const entryPoint = await context.installDeb(packagePath);
await testDesktopApp(entryPoint);
- await testAgentsApp(entryPoint);
await context.uninstallDeb();
}
});
@@ -141,7 +136,6 @@ export function setup(context: TestContext) {
if (!context.options.downloadOnly) {
const entryPoint = context.installRpm(packagePath);
await testDesktopApp(entryPoint);
- await testAgentsApp(entryPoint);
await context.uninstallRpm();
}
});
@@ -151,7 +145,6 @@ export function setup(context: TestContext) {
if (!context.options.downloadOnly) {
const entryPoint = context.installRpm(packagePath);
await testDesktopApp(entryPoint);
- await testAgentsApp(entryPoint);
await context.uninstallRpm();
}
});
@@ -161,7 +154,6 @@ export function setup(context: TestContext) {
if (!context.options.downloadOnly) {
const entryPoint = context.installRpm(packagePath);
await testDesktopApp(entryPoint);
- await testAgentsApp(entryPoint);
await context.uninstallRpm();
}
});
@@ -171,7 +163,6 @@ export function setup(context: TestContext) {
if (!context.options.downloadOnly) {
const entryPoint = context.installSnap(packagePath);
await testDesktopApp(entryPoint);
- await testAgentsApp(entryPoint);
await context.uninstallSnap();
}
});
@@ -183,7 +174,6 @@ export function setup(context: TestContext) {
const entryPoint = context.getDesktopEntryPoint(dir);
const dataDir = context.createPortableDataDir(dir);
await testDesktopApp(entryPoint, dataDir);
- await testAgentsApp(entryPoint, dataDir);
}
});
@@ -295,6 +285,11 @@ export function setup(context: TestContext) {
}
async function testAgentsApp(desktopEntryPoint: string, dataDir?: string) {
+ if (context.options.quality === 'stable') {
+ // Agents app is not included in stable builds yet.
+ return;
+ }
+
const test = new UITest(context, dataDir);
const args = ['--agents'];
if (!dataDir) {
diff --git a/test/sanity/src/devTunnel.test.ts b/test/sanity/src/devTunnel.test.ts
index 7de7245034219..adb2a85ad6182 100644
--- a/test/sanity/src/devTunnel.test.ts
+++ b/test/sanity/src/devTunnel.test.ts
@@ -110,7 +110,7 @@ export function setup(context: TestContext) {
const tunnelUrl = /Open this link in your browser (https?:\/\/[^\s]+)/.exec(line)?.[1];
if (tunnelUrl) {
await connectToTunnel(tunnelUrl, page, test, auth);
- await test.run(page, true);
+ await test.run(page);
test.validate();
return true;
}
diff --git a/test/sanity/src/uiTest.ts b/test/sanity/src/uiTest.ts
index 570ccb64c2b4a..c2e3e56f6853b 100644
--- a/test/sanity/src/uiTest.ts
+++ b/test/sanity/src/uiTest.ts
@@ -51,11 +51,9 @@ export class UITest {
/**
* Run the UI test actions.
*/
- public async run(page: Page, skipWelcome = false) {
+ public async run(page: Page) {
try {
- if (!skipWelcome) {
- await this.dismissWelcomeDialog(page);
- }
+ await this.dismissWelcomeDialog(page);
await this.dismissWorkspaceTrustDialog(page);
await this.createTextFile(page);
await this.installExtension(page);
@@ -74,12 +72,17 @@ export class UITest {
}
/**
- * Dismiss the welcome sign-in dialog.
+ * Dismiss the welcome sign-in dialog if it is shown.
*/
public async dismissWelcomeDialog(page: Page) {
- this.context.log('Dismissing welcome dialog');
+ this.context.log('Dismissing welcome dialog (if shown)');
const skipButton = page.getByRole('button', { name: 'Skip' });
- await skipButton.waitFor({ state: 'visible' });
+ try {
+ await skipButton.waitFor({ state: 'visible', timeout: 5_000 });
+ } catch {
+ this.context.log('Welcome dialog not shown, continuing');
+ return;
+ }
await skipButton.click();
await skipButton.waitFor({ state: 'hidden' });
}
diff --git a/test/sanity/src/wsl.test.ts b/test/sanity/src/wsl.test.ts
index 29817ed9233a3..85fe17c5018b0 100644
--- a/test/sanity/src/wsl.test.ts
+++ b/test/sanity/src/wsl.test.ts
@@ -170,7 +170,7 @@ export function setup(context: TestContext) {
throw error;
}
- await test.run(window, true);
+ await test.run(window);
} finally {
context.log('Closing the application');
await app.close();