mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-23 23:56:09 +01:00
Merge branch 'main' into agents/log-analysis-error-fix-prioritization-58b68a87
This commit is contained in:
@@ -33,6 +33,8 @@ export const enum SessionConfigKey {
|
||||
WorktreeIncludeFiles = 'worktreeIncludeFiles',
|
||||
/** `'worktreeBranchTrack'` — host-owned branch tracking preference for programmatic session creation. */
|
||||
WorktreeBranchTrack = 'worktreeBranchTrack',
|
||||
/** `'worktreeCreateNewBranch'` — host-owned choice to create a branch instead of checking out the selected branch. */
|
||||
WorktreeCreateNewBranch = 'worktreeCreateNewBranch',
|
||||
/** `'agentMerge'` — client-owned Agent Merge enablement and session overrides. */
|
||||
AgentMerge = 'agentMerge',
|
||||
/** `'agentMerge.controller'` — host-owned Agent Merge lifecycle state. */
|
||||
|
||||
@@ -215,7 +215,8 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC
|
||||
}
|
||||
|
||||
private _hasWorkingDirectory(session: ProtocolURI): boolean {
|
||||
return !!this._configurationService.getEffectiveWorkingDirectories(session)?.[0];
|
||||
return !this._configurationService.isWorkingDirectoryPending(session)
|
||||
&& !!this._configurationService.getEffectiveWorkingDirectories(session)?.[0];
|
||||
}
|
||||
|
||||
registerStaticChangesets(session: ProtocolURI): void {
|
||||
|
||||
@@ -338,8 +338,10 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi
|
||||
|
||||
async resolveSessionBaseBranchName(sessionKey: string): Promise<string | undefined> {
|
||||
const state = this._stateManager.getSessionState(sessionKey);
|
||||
const configuredBranch = state?.config?.values[SessionConfigKey.Isolation] === 'worktree'
|
||||
? state.config.values[SessionConfigKey.Branch]
|
||||
const configValues = state?.config?.values;
|
||||
const configuredBranch = configValues?.[SessionConfigKey.Isolation] === 'worktree'
|
||||
&& configValues[SessionConfigKey.WorktreeCreateNewBranch] !== false
|
||||
? configValues[SessionConfigKey.Branch]
|
||||
: undefined;
|
||||
if (typeof configuredBranch === 'string' && configuredBranch.trim()) {
|
||||
return resolveDiffBaseBranchName(configuredBranch.trim(), undefined);
|
||||
|
||||
@@ -140,6 +140,7 @@ const HOST_OWNED_SESSION_CONFIG_KEYS = [
|
||||
SessionConfigKey.WorktreeBranchPrefix,
|
||||
SessionConfigKey.WorktreeIncludeFiles,
|
||||
SessionConfigKey.WorktreeBranchTrack,
|
||||
SessionConfigKey.WorktreeCreateNewBranch,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -2689,8 +2690,10 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
}
|
||||
}
|
||||
|
||||
const workingDirectory = created.resolvedWorkingDirectory ?? config?.workingDirectories?.[0];
|
||||
void this._gitStateService.refreshSessionGitState(session.toString(), workingDirectory);
|
||||
if (!this._configurationService.isWorkingDirectoryPending(session.toString())) {
|
||||
const workingDirectory = created.resolvedWorkingDirectory ?? config?.workingDirectories?.[0];
|
||||
void this._gitStateService.refreshSessionGitState(session.toString(), workingDirectory);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
@@ -3643,6 +3646,9 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
if (iso.worktreeBranchTrackProperty) {
|
||||
properties[SessionConfigKey.WorktreeBranchTrack] = iso.worktreeBranchTrackProperty.protocol;
|
||||
}
|
||||
if (iso.worktreeCreateNewBranchProperty) {
|
||||
properties[SessionConfigKey.WorktreeCreateNewBranch] = iso.worktreeCreateNewBranchProperty.protocol;
|
||||
}
|
||||
if (iso.worktreeIncludeFilesProperty) {
|
||||
properties[SessionConfigKey.WorktreeIncludeFiles] = iso.worktreeIncludeFilesProperty.protocol;
|
||||
}
|
||||
@@ -3657,6 +3663,9 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
if (iso.worktreeBranchTrackProperty && typeof params.config?.[SessionConfigKey.WorktreeBranchTrack] === 'boolean') {
|
||||
values[SessionConfigKey.WorktreeBranchTrack] = params.config[SessionConfigKey.WorktreeBranchTrack];
|
||||
}
|
||||
if (iso.worktreeCreateNewBranchProperty && typeof params.config?.[SessionConfigKey.WorktreeCreateNewBranch] === 'boolean') {
|
||||
values[SessionConfigKey.WorktreeCreateNewBranch] = params.config[SessionConfigKey.WorktreeCreateNewBranch];
|
||||
}
|
||||
if (iso.worktreeIncludeFilesProperty
|
||||
&& Array.isArray(params.config?.[SessionConfigKey.WorktreeIncludeFiles])
|
||||
&& params.config[SessionConfigKey.WorktreeIncludeFiles].every(pattern => typeof pattern === 'string')) {
|
||||
|
||||
@@ -45,7 +45,7 @@ export const artifactServerToolDefinitions: ToolDefinition[] = [
|
||||
{
|
||||
name: ArtifactServerToolName.AddArtifact,
|
||||
title: 'Add Artifact',
|
||||
description: 'Record something the user will want to open — a pull request, issue, notable commit, website, file or other resource — so it is surfaced next to the chat input.',
|
||||
description: 'Record something the user will want to open — a pull request, issue, commit found while investigating or answering a question, website, file or other resource — so it is surfaced next to the chat input. Do not record commits you create unless the user explicitly asks you to add them as artifacts.',
|
||||
inputSchema: addArtifactInputSchema,
|
||||
annotations: { readOnlyHint: false },
|
||||
},
|
||||
@@ -172,4 +172,4 @@ export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAcce
|
||||
* The instruction appended to every agent's host instructions while the
|
||||
* artifact tools are enabled.
|
||||
*/
|
||||
export const ARTIFACT_TOOLS_INSTRUCTION = `When you produce something the user will want to open — a pull request, an issue, a notable commit, a website, a plan file or another resource — record it once with \`${ArtifactServerToolName.AddArtifact}\` (types: ${SESSION_ARTIFACT_TYPES.join(', ')}; use \`${SessionArtifactType.Resource}\` when nothing else fits). Do not record routine files you merely edited, and do not record every commit you make — record a commit only when the user asked you to commit, or when you found a commit worth showing them, for example while investigating.`;
|
||||
export const ARTIFACT_TOOLS_INSTRUCTION = `When you produce something the user will want to open — a pull request, an issue, a website, a plan file or another resource — or find a notable commit worth showing the user while investigating or answering a question, record it once with \`${ArtifactServerToolName.AddArtifact}\` (types: ${SESSION_ARTIFACT_TYPES.join(', ')}; use \`${SessionArtifactType.Resource}\` when nothing else fits). Do not record routine files you merely edited. Do not record commits you create unless the user explicitly asks you to add them as artifacts.`;
|
||||
|
||||
@@ -293,6 +293,8 @@ export interface IIsolationConfigContribution {
|
||||
readonly worktreeIncludeFilesProperty: ISchemaProperty<readonly string[]> | undefined;
|
||||
/** Read-only carrier for the programmatic worktree branch tracking preference. */
|
||||
readonly worktreeBranchTrackProperty: ISchemaProperty<boolean> | undefined;
|
||||
/** Read-only carrier for checking out the selected branch directly. */
|
||||
readonly worktreeCreateNewBranchProperty: ISchemaProperty<boolean> | undefined;
|
||||
readonly isolationValue: 'folder' | 'worktree';
|
||||
readonly branchDefault: string | undefined;
|
||||
readonly branchValue: string | undefined;
|
||||
@@ -475,6 +477,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI
|
||||
let worktreeBranchPrefixProperty: ISchemaProperty<string> | undefined;
|
||||
let worktreeIncludeFilesProperty: ISchemaProperty<readonly string[]> | undefined;
|
||||
let worktreeBranchTrackProperty: ISchemaProperty<boolean> | undefined;
|
||||
let worktreeCreateNewBranchProperty: ISchemaProperty<boolean> | undefined;
|
||||
if (gitInfo) {
|
||||
const branchReadOnly = isolationValue === 'folder';
|
||||
branchDefault = isolationValue === 'worktree' ? gitInfo.defaultBranch.name : gitInfo.currentBranch;
|
||||
@@ -520,6 +523,15 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI
|
||||
sessionMutable: false,
|
||||
});
|
||||
|
||||
worktreeCreateNewBranchProperty = schemaProperty<boolean>({
|
||||
type: 'boolean',
|
||||
title: localize('agentHost.sessionConfig.worktreeCreateNewBranch', "Create New Worktree Branch"),
|
||||
description: localize('agentHost.sessionConfig.worktreeCreateNewBranchDescription', "Whether to create a new branch for the isolated worktree."),
|
||||
default: true,
|
||||
readOnly: true,
|
||||
sessionMutable: false,
|
||||
});
|
||||
|
||||
worktreeIncludeFilesProperty = schemaProperty<readonly string[]>({
|
||||
type: 'array',
|
||||
title: localize('agentHost.sessionConfig.worktreeIncludeFiles', "Worktree Include Files"),
|
||||
@@ -533,7 +545,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI
|
||||
});
|
||||
}
|
||||
|
||||
return { isolationProperty, branchProperty, worktreeBranchPrefixProperty, worktreeBranchTrackProperty, worktreeIncludeFilesProperty, isolationValue, branchDefault, branchValue };
|
||||
return { isolationProperty, branchProperty, worktreeBranchPrefixProperty, worktreeBranchTrackProperty, worktreeCreateNewBranchProperty, worktreeIncludeFilesProperty, isolationValue, branchDefault, branchValue };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -563,7 +575,7 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI
|
||||
/**
|
||||
* Resolves the effective working directory for a session that is about to
|
||||
* be materialized. When the session config selects `worktree` isolation on
|
||||
* a git repository, creates a fresh branch + worktree, records it for
|
||||
* a git repository, creates or checks out a branch in a worktree, records it for
|
||||
* cleanup, queues the first-turn announcement, persists the worktree
|
||||
* metadata, and returns the worktree URI. Otherwise returns the requested
|
||||
* working directory unchanged.
|
||||
@@ -591,50 +603,69 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI
|
||||
}
|
||||
|
||||
const repositoryRoot = await this._resolvePrimaryWorktreeRoot(checkoutRoot, checkoutRoot);
|
||||
const worktreesRoot = getWorktreesRoot(repositoryRoot);
|
||||
|
||||
const selectedBranch = config[SessionConfigKey.Branch] as string;
|
||||
const worktreeBranchTrack = config[SessionConfigKey.WorktreeBranchTrack] === true;
|
||||
const worktreeCreateNewBranch = config[SessionConfigKey.WorktreeCreateNewBranch] !== false;
|
||||
|
||||
// Prefix (e.g. the user's `git.branchPrefix`) the client forwards for
|
||||
// worktree-isolated sessions. Prepended ahead of the built-in `agents/`
|
||||
// prefix when naming the branch and stripped from the worktree dir name.
|
||||
const worktreeBranchPrefix = typeof config[SessionConfigKey.WorktreeBranchPrefix] === 'string'
|
||||
const worktreeBranchPrefix = worktreeCreateNewBranch && typeof config[SessionConfigKey.WorktreeBranchPrefix] === 'string'
|
||||
? config[SessionConfigKey.WorktreeBranchPrefix] as string
|
||||
: undefined;
|
||||
const selectedBranch = config[SessionConfigKey.Branch] as string;
|
||||
const { branchName, worktree, baseBranch } = await this._worktreeCreationSequencer.queue(repositoryRoot.toString(), async () => {
|
||||
onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.NamingBranch));
|
||||
const branchName = await this._branchNameGenerator.generateBranchName({
|
||||
sessionId,
|
||||
message: prompt,
|
||||
githubToken,
|
||||
branchPrefix: worktreeBranchPrefix,
|
||||
branchNameCollides: async candidate => {
|
||||
if (await this._gitService.branchExists(repositoryRoot, candidate).catch(() => true)) {
|
||||
return true;
|
||||
}
|
||||
const candidateWorktree = URI.joinPath(worktreesRoot, getWorktreeName(candidate, worktreeBranchPrefix));
|
||||
return fileExists(candidateWorktree.fsPath);
|
||||
},
|
||||
});
|
||||
const worktree = URI.joinPath(worktreesRoot, getWorktreeName(branchName, worktreeBranchPrefix));
|
||||
const baseBranch = await this._resolveBranchStartPoint(repositoryRoot, selectedBranch);
|
||||
await fs.mkdir(worktreesRoot.fsPath, { recursive: true });
|
||||
|
||||
const { worktreePath, branchName, baseBranch } = await this._worktreeCreationSequencer.queue(repositoryRoot.toString(), async () => {
|
||||
const worktreesRoot = getWorktreesRoot(repositoryRoot);
|
||||
|
||||
if (worktreeCreateNewBranch) {
|
||||
onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.NamingBranch));
|
||||
}
|
||||
const newBranchName = worktreeCreateNewBranch
|
||||
? await this._branchNameGenerator.generateBranchName({
|
||||
sessionId,
|
||||
message: prompt,
|
||||
githubToken,
|
||||
branchPrefix: worktreeBranchPrefix,
|
||||
branchNameCollides: async candidate => {
|
||||
if (await this._gitService.branchExists(repositoryRoot, candidate).catch(() => true)) {
|
||||
return true;
|
||||
}
|
||||
const candidateWorktree = URI.joinPath(worktreesRoot, getWorktreeName(candidate, worktreeBranchPrefix));
|
||||
return fileExists(candidateWorktree.fsPath);
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const branchStartPoint = await this._resolveBranchStartPoint(repositoryRoot, selectedBranch);
|
||||
|
||||
const baseBranch = worktreeCreateNewBranch
|
||||
? branchStartPoint
|
||||
: (await this._gitService.getDefaultBranch(repositoryRoot))?.startPoint;
|
||||
|
||||
// Git suppresses progress for the first couple of seconds, so name
|
||||
// the phase up front rather than leaving the label stale until the
|
||||
// first percentage arrives.
|
||||
onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.CheckingOut));
|
||||
|
||||
const worktreeBranchTrack = config[SessionConfigKey.WorktreeBranchTrack] === true;
|
||||
await fs.mkdir(worktreesRoot.fsPath, { recursive: true });
|
||||
const worktreePath = URI.joinPath(worktreesRoot, getWorktreeName(newBranchName ?? selectedBranch, worktreeBranchPrefix));
|
||||
|
||||
await withPercentProgress(WorktreeCreationPhase.CheckingOut, onProgress, progress =>
|
||||
this._gitService.addWorktree(repositoryRoot, {
|
||||
path: worktree,
|
||||
commitish: baseBranch,
|
||||
newBranchName: branchName,
|
||||
path: worktreePath,
|
||||
commitish: worktreeCreateNewBranch
|
||||
? branchStartPoint
|
||||
: selectedBranch,
|
||||
newBranchName,
|
||||
preferRemoteBranch: worktreeCreateNewBranch,
|
||||
track: worktreeBranchTrack,
|
||||
preferRemoteBranch: true,
|
||||
onProgress: progress,
|
||||
}));
|
||||
return { branchName, worktree, baseBranch };
|
||||
|
||||
return { branchName: newBranchName ?? selectedBranch, worktreePath, baseBranch };
|
||||
});
|
||||
|
||||
const worktreeIncludeFiles = Array.isArray(config[SessionConfigKey.WorktreeIncludeFiles])
|
||||
&& config[SessionConfigKey.WorktreeIncludeFiles].every(pattern => typeof pattern === 'string')
|
||||
? config[SessionConfigKey.WorktreeIncludeFiles] as readonly string[]
|
||||
@@ -643,21 +674,25 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI
|
||||
try {
|
||||
onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.CopyingIncludeFiles));
|
||||
await withPercentProgress(WorktreeCreationPhase.CopyingIncludeFiles, onProgress, progress =>
|
||||
this._gitService.copyWorktreeIncludeFiles(checkoutRoot, worktree, worktreeIncludeFiles, progress));
|
||||
this._gitService.copyWorktreeIncludeFiles(checkoutRoot, worktreePath, worktreeIncludeFiles, progress));
|
||||
} catch (error) {
|
||||
this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to copy worktree include files: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
this._materializedWorktrees.set(sessionId, { repositoryRoot, worktree });
|
||||
|
||||
this._materializedWorktrees.set(sessionId, { repositoryRoot, worktree: worktreePath });
|
||||
|
||||
// Queue the worktree announcement so the first turn (live) and any
|
||||
// subsequent restore (history) both surface the message in the chat.
|
||||
this._pendingFirstTurnAnnouncements.set(sessionId, buildWorktreeAnnouncementText(branchName));
|
||||
|
||||
try {
|
||||
await this._writeWorktreeMetadata(sessionUri, { branchName, baseBranch, worktreePath: worktree, repositoryRoot });
|
||||
await this._writeWorktreeMetadata(sessionUri, { repositoryRoot, worktreePath, baseBranch, branchName });
|
||||
} catch (error) {
|
||||
this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to persist worktree branch metadata: ${errorMessage(error)}`);
|
||||
}
|
||||
return worktree;
|
||||
|
||||
return worktreePath;
|
||||
}
|
||||
|
||||
/** Resolves a persisted working directory, repairing a removed worktree when possible. */
|
||||
|
||||
@@ -764,6 +764,68 @@ suite('AgentHostGitService - worktree helpers (real git)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
(hasGit ? test : test.skip)('addWorktree preserves tracking when attaching an existing branch', async () => {
|
||||
const dir = initRepo();
|
||||
const remotePath = join(dir, 'remote.git');
|
||||
cp.execFileSync('git', ['init', '--bare', '-q', remotePath], { cwd: dir, env, stdio: 'pipe' });
|
||||
cp.execFileSync('git', ['remote', 'add', 'origin', remotePath], { cwd: dir, env, stdio: 'pipe' });
|
||||
cp.execFileSync('git', ['push', '-q', 'origin', 'main'], { cwd: dir, env, stdio: 'pipe' });
|
||||
cp.execFileSync('git', ['branch', 'feature'], { cwd: dir, env, stdio: 'pipe' });
|
||||
cp.execFileSync('git', ['push', '-q', '--set-upstream', 'origin', 'feature'], { cwd: dir, env, stdio: 'pipe' });
|
||||
const wtPath = join(dir, '..', `wt-${Date.now()}`);
|
||||
try {
|
||||
await svc!.addWorktree(URI.file(dir), {
|
||||
path: URI.file(wtPath),
|
||||
commitish: 'feature',
|
||||
track: true,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual({
|
||||
branch: cp.execFileSync('git', ['branch', '--show-current'], { cwd: wtPath, env, encoding: 'utf8' }).trim(),
|
||||
upstream: cp.execFileSync('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { cwd: wtPath, env, encoding: 'utf8' }).trim(),
|
||||
}, {
|
||||
branch: 'feature',
|
||||
upstream: 'origin/feature',
|
||||
});
|
||||
} finally {
|
||||
try { await svc!.removeWorktree(URI.file(dir), URI.file(wtPath), { force: true }); } catch { /* best-effort cleanup */ }
|
||||
rmDirWithRetry(wtPath);
|
||||
}
|
||||
});
|
||||
|
||||
(hasGit ? test : test.skip)('addWorktree automatically tracks a remote branch when creating its local branch', async () => {
|
||||
const dir = initRepo();
|
||||
const remotePath = join(dir, 'remote.git');
|
||||
cp.execFileSync('git', ['init', '--bare', '-q', remotePath], { cwd: dir, env, stdio: 'pipe' });
|
||||
cp.execFileSync('git', ['remote', 'add', 'origin', remotePath], { cwd: dir, env, stdio: 'pipe' });
|
||||
cp.execFileSync('git', ['checkout', '-q', '-b', 'feature'], { cwd: dir, env, stdio: 'pipe' });
|
||||
cp.execFileSync('git', ['commit', '-q', '--allow-empty', '-m', 'feature'], { cwd: dir, env, stdio: 'pipe' });
|
||||
cp.execFileSync('git', ['push', '-q', 'origin', 'feature'], { cwd: dir, env, stdio: 'pipe' });
|
||||
cp.execFileSync('git', ['checkout', '-q', 'main'], { cwd: dir, env, stdio: 'pipe' });
|
||||
cp.execFileSync('git', ['branch', '-D', 'feature'], { cwd: dir, env, stdio: 'pipe' });
|
||||
const wtPath = join(dir, '..', `wt-${Date.now()}`);
|
||||
try {
|
||||
await svc!.addWorktree(URI.file(dir), {
|
||||
path: URI.file(wtPath),
|
||||
commitish: 'feature',
|
||||
newBranchName: 'feature',
|
||||
track: true,
|
||||
preferRemoteBranch: true,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual({
|
||||
branch: cp.execFileSync('git', ['branch', '--show-current'], { cwd: wtPath, env, encoding: 'utf8' }).trim(),
|
||||
upstream: cp.execFileSync('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { cwd: wtPath, env, encoding: 'utf8' }).trim(),
|
||||
}, {
|
||||
branch: 'feature',
|
||||
upstream: 'origin/feature',
|
||||
});
|
||||
} finally {
|
||||
try { await svc!.removeWorktree(URI.file(dir), URI.file(wtPath), { force: true }); } catch { /* best-effort cleanup */ }
|
||||
rmDirWithRetry(wtPath);
|
||||
}
|
||||
});
|
||||
|
||||
(hasGit ? test : test.skip)('removeWorktree preserves dirty work unless forced', async () => {
|
||||
const dir = initRepo();
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
@@ -210,7 +210,7 @@ suite('AgentHostGitStateService', () => {
|
||||
};
|
||||
}
|
||||
|
||||
function seedSession(stateManager: AgentHostStateManager, options?: { workingDirectory?: string; project?: string; gitState?: ISessionGitState; gitHubState?: ISessionGitHubState; isolation?: 'folder' | 'worktree'; baseBranch?: string; createdAt?: number }): void {
|
||||
function seedSession(stateManager: AgentHostStateManager, options?: { workingDirectory?: string; project?: string; gitState?: ISessionGitState; gitHubState?: ISessionGitHubState; isolation?: 'folder' | 'worktree'; baseBranch?: string; createNewBranch?: boolean; createdAt?: number }): void {
|
||||
const summary: SessionSummary = {
|
||||
resource: SESSION,
|
||||
provider: 'mock',
|
||||
@@ -230,6 +230,7 @@ suite('AgentHostGitStateService', () => {
|
||||
values: {
|
||||
[SessionConfigKey.Isolation]: options.isolation,
|
||||
...(options.baseBranch ? { [SessionConfigKey.Branch]: options.baseBranch } : {}),
|
||||
...(options.createNewBranch !== undefined ? { [SessionConfigKey.WorktreeCreateNewBranch]: options.createNewBranch } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -314,6 +315,23 @@ suite('AgentHostGitStateService', () => {
|
||||
assert.deepStrictEqual(h.gitBaseBranches, ['release']);
|
||||
}));
|
||||
|
||||
test('uses the persisted base branch when the selected branch is checked out directly', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const h = createHarness();
|
||||
seedSession(h.stateManager, {
|
||||
workingDirectory: WORKING_DIRECTORY,
|
||||
project: 'file:///repo',
|
||||
isolation: 'worktree',
|
||||
baseBranch: 'feature/pr',
|
||||
createNewBranch: false,
|
||||
});
|
||||
await h.db.setMetadata(META_DIFF_BASE_BRANCH, 'origin/main');
|
||||
h.setGitResult({ branchName: 'feature/pr', baseBranchName: 'main' });
|
||||
|
||||
await h.service.refreshSessionGitState(SESSION, undefined);
|
||||
|
||||
assert.deepStrictEqual(h.gitBaseBranches, ['main']);
|
||||
}));
|
||||
|
||||
test('uses the persisted worktree base branch for an adopted linked worktree', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const h = createHarness();
|
||||
seedSession(h.stateManager, {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { ClaudeSessionConfigKey } from '../../common/claudeSessionConfigKeys.js'
|
||||
import { CodexSessionConfigKey } from '../../common/codexSessionConfigKeys.js';
|
||||
import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
|
||||
import { META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../../common/agentHostGitStateService.js';
|
||||
import { GitRefType } from '../../common/agentHostGitService.js';
|
||||
import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
|
||||
import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js';
|
||||
import { SessionDatabase } from '../../node/sessionDatabase.js';
|
||||
@@ -783,6 +784,7 @@ suite('AgentService (node dispatcher)', () => {
|
||||
[SessionConfigKey.WorktreeBranchPrefix]: 'users/test/',
|
||||
[SessionConfigKey.WorktreeIncludeFiles]: ['.env'],
|
||||
[SessionConfigKey.WorktreeBranchTrack]: false,
|
||||
[SessionConfigKey.WorktreeCreateNewBranch]: false,
|
||||
providerSetting: 'selected',
|
||||
},
|
||||
});
|
||||
@@ -800,6 +802,7 @@ suite('AgentService (node dispatcher)', () => {
|
||||
[SessionConfigKey.WorktreeBranchPrefix]: 'users/test/',
|
||||
[SessionConfigKey.WorktreeIncludeFiles]: ['.env'],
|
||||
[SessionConfigKey.WorktreeBranchTrack]: false,
|
||||
[SessionConfigKey.WorktreeCreateNewBranch]: false,
|
||||
providerSetting: 'completion',
|
||||
},
|
||||
property: 'providerSetting',
|
||||
@@ -822,6 +825,7 @@ suite('AgentService (node dispatcher)', () => {
|
||||
branchPrefix: selected.values[SessionConfigKey.WorktreeBranchPrefix],
|
||||
includeFiles: selected.values[SessionConfigKey.WorktreeIncludeFiles],
|
||||
branchTrack: selected.values[SessionConfigKey.WorktreeBranchTrack],
|
||||
createNewBranch: selected.values[SessionConfigKey.WorktreeCreateNewBranch],
|
||||
providerSetting: selected.values.providerSetting,
|
||||
},
|
||||
folder: {
|
||||
@@ -844,7 +848,7 @@ suite('AgentService (node dispatcher)', () => {
|
||||
agentMergeController: { lastPromptFingerprint: 'fingerprint' },
|
||||
providerSetting: 'initial',
|
||||
},
|
||||
selected: { isolation: 'worktree', branch: 'feature/config', branchPrefix: 'users/test/', includeFiles: ['.env'], branchTrack: false, providerSetting: 'selected' },
|
||||
selected: { isolation: 'worktree', branch: 'feature/config', branchPrefix: 'users/test/', includeFiles: ['.env'], branchTrack: false, createNewBranch: false, providerSetting: 'selected' },
|
||||
folder: { isolation: 'folder', branch: 'feature', providerSetting: 'folder' },
|
||||
});
|
||||
});
|
||||
@@ -12341,6 +12345,107 @@ suite('AgentService (node dispatcher)', () => {
|
||||
assert.strictEqual(state?.workingDirectories?.[0], worktreeDir.toString());
|
||||
});
|
||||
|
||||
test('pending worktree session defers git state and branch changes until materialization', async () => {
|
||||
class ProvisionalWorktreeAgent extends MockAgent {
|
||||
private readonly _onDidMaterializeChat = new Emitter<IAgentMaterializeChatEvent>();
|
||||
override readonly onDidMaterializeChat = this._onDidMaterializeChat.event;
|
||||
override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({
|
||||
createChat: (chat, context, options) => createProvisionalChat(base, chat, context, options),
|
||||
}));
|
||||
|
||||
materialize(session: URI, workingDirectory: URI): void {
|
||||
this._onDidMaterializeChat.fire({
|
||||
chat: URI.parse(buildDefaultChatUri(session)),
|
||||
workingDirectories: [workingDirectory],
|
||||
project: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this._onDidMaterializeChat.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
const sourceDir = URI.file('/source/repo');
|
||||
const worktreeDir = URI.file('/source/repo.worktrees/feature');
|
||||
const gitStateCalls: Array<{ resource: string; baseBranch: string | undefined }> = [];
|
||||
const diffCalls: string[] = [];
|
||||
const gitService = createNoopGitService();
|
||||
gitService.getRepositoryRoot = async () => sourceDir;
|
||||
gitService.revParse = async () => 'head';
|
||||
gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'origin/main' });
|
||||
gitService.getCurrentBranch = async () => 'main';
|
||||
gitService.getBranches = async () => [{ ref: 'refs/heads/main', name: 'main', kind: GitRefType.Head }];
|
||||
gitService.getSessionGitState = async (resource, baseBranch) => {
|
||||
gitStateCalls.push({ resource: resource.toString(), baseBranch });
|
||||
return { branchName: 'feature', baseBranchName: 'main' };
|
||||
};
|
||||
gitService.computeSessionFileDiffs = async resource => {
|
||||
diffCalls.push(resource.toString());
|
||||
return [];
|
||||
};
|
||||
|
||||
const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService));
|
||||
const isolation = disposables.add(new WorktreeIsolation(
|
||||
{ generateBranchName: async () => { throw new Error('should not generate a branch'); } },
|
||||
gitService,
|
||||
new TestCopilotApiService(),
|
||||
nullSessionDataService,
|
||||
new NullLogService(),
|
||||
));
|
||||
localService.setWorktreeIsolation(isolation);
|
||||
const agent = new ProvisionalWorktreeAgent('copilot');
|
||||
disposables.add(toDisposable(() => agent.dispose()));
|
||||
localService.registerProvider(agent);
|
||||
|
||||
const session = await localService.createSession({
|
||||
provider: agent.id,
|
||||
workingDirectories: [sourceDir],
|
||||
config: {
|
||||
[SessionConfigKey.Isolation]: 'worktree',
|
||||
[SessionConfigKey.Branch]: 'feature',
|
||||
[SessionConfigKey.WorktreeCreateNewBranch]: false,
|
||||
},
|
||||
});
|
||||
const branchChangeset = buildBranchChangesetUri(session.toString());
|
||||
localService.addSubscriber(URI.parse(branchChangeset), 'client-1');
|
||||
await timeout(0);
|
||||
|
||||
const beforeMaterialization = {
|
||||
workingDirectory: localService.stateManager.getSessionState(session.toString())?.workingDirectories?.[0],
|
||||
gitStateCalls: [...gitStateCalls],
|
||||
diffCalls: [...diffCalls],
|
||||
};
|
||||
|
||||
isolation.clearPending(AgentSession.id(session));
|
||||
agent.materialize(session, worktreeDir);
|
||||
for (let i = 0; i < 20 && (gitStateCalls.length === 0 || diffCalls.length === 0); i++) {
|
||||
await timeout(0);
|
||||
}
|
||||
|
||||
assert.deepStrictEqual({
|
||||
beforeMaterialization,
|
||||
afterMaterialization: {
|
||||
workingDirectory: localService.stateManager.getSessionState(session.toString())?.workingDirectories?.[0],
|
||||
gitStateCalls,
|
||||
diffCalls: [...new Set(diffCalls)],
|
||||
},
|
||||
}, {
|
||||
beforeMaterialization: {
|
||||
workingDirectory: sourceDir.toString(),
|
||||
gitStateCalls: [],
|
||||
diffCalls: [],
|
||||
},
|
||||
afterMaterialization: {
|
||||
workingDirectory: worktreeDir.toString(),
|
||||
gitStateCalls: [{ resource: worktreeDir.toString(), baseBranch: undefined }],
|
||||
diffCalls: [worktreeDir.toString()],
|
||||
},
|
||||
});
|
||||
localService.unsubscribe(URI.parse(branchChangeset), 'client-1');
|
||||
});
|
||||
|
||||
test('_resolveWorkingDirectoryBeforeSend returns the full set (index 0 + tail), or undefined when unset', async () => {
|
||||
const resolver = service as unknown as {
|
||||
_resolveWorkingDirectoryBeforeSend: (p: { session: string; chat: string; turnId: string; prompt: string }) => Promise<readonly URI[] | undefined>;
|
||||
|
||||
@@ -152,17 +152,17 @@ suite('WorktreeIsolation', () => {
|
||||
const noCommits = await isolation.resolveIsolationConfig({ workingDirectory: repoRoot, config: undefined });
|
||||
|
||||
assert.deepStrictEqual({
|
||||
noRepo: { enum: noRepo.isolationProperty.protocol.enum, value: noRepo.isolationValue, branch: noRepo.branchProperty, prefix: noRepo.worktreeBranchPrefixProperty, includeFiles: noRepo.worktreeIncludeFilesProperty, branchTrack: noRepo.worktreeBranchTrackProperty },
|
||||
repoWorktree: { enum: repoWorktree.isolationProperty.protocol.enum, value: repoWorktree.isolationValue, branchDefault: repoWorktree.branchDefault, branchReadOnly: repoWorktree.branchProperty?.protocol.readOnly, prefixReadOnly: repoWorktree.worktreeBranchPrefixProperty?.protocol.readOnly, includeFilesReadOnly: repoWorktree.worktreeIncludeFilesProperty?.protocol.readOnly, branchTrackReadOnly: repoWorktree.worktreeBranchTrackProperty?.protocol.readOnly },
|
||||
noRepo: { enum: noRepo.isolationProperty.protocol.enum, value: noRepo.isolationValue, branch: noRepo.branchProperty, prefix: noRepo.worktreeBranchPrefixProperty, includeFiles: noRepo.worktreeIncludeFilesProperty, branchTrack: noRepo.worktreeBranchTrackProperty, createNewBranch: noRepo.worktreeCreateNewBranchProperty },
|
||||
repoWorktree: { enum: repoWorktree.isolationProperty.protocol.enum, value: repoWorktree.isolationValue, branchDefault: repoWorktree.branchDefault, branchReadOnly: repoWorktree.branchProperty?.protocol.readOnly, prefixReadOnly: repoWorktree.worktreeBranchPrefixProperty?.protocol.readOnly, includeFilesReadOnly: repoWorktree.worktreeIncludeFilesProperty?.protocol.readOnly, branchTrackReadOnly: repoWorktree.worktreeBranchTrackProperty?.protocol.readOnly, createNewBranchReadOnly: repoWorktree.worktreeCreateNewBranchProperty?.protocol.readOnly },
|
||||
repoWorktreeSelected: { branchDefault: repoWorktreeSelected.branchDefault, branchValue: repoWorktreeSelected.branchValue, branchEnum: repoWorktreeSelected.branchProperty?.protocol.enum },
|
||||
repoFolder: { value: repoFolder.isolationValue, branchDefault: repoFolder.branchDefault, branchReadOnly: repoFolder.branchProperty?.protocol.readOnly, hasPrefix: !!repoFolder.worktreeBranchPrefixProperty, hasIncludeFiles: !!repoFolder.worktreeIncludeFilesProperty, hasBranchTrack: !!repoFolder.worktreeBranchTrackProperty },
|
||||
noCommits: { enum: noCommits.isolationProperty.protocol.enum, value: noCommits.isolationValue, branch: noCommits.branchProperty, prefix: noCommits.worktreeBranchPrefixProperty, includeFiles: noCommits.worktreeIncludeFilesProperty, branchTrack: noCommits.worktreeBranchTrackProperty },
|
||||
repoFolder: { value: repoFolder.isolationValue, branchDefault: repoFolder.branchDefault, branchReadOnly: repoFolder.branchProperty?.protocol.readOnly, hasPrefix: !!repoFolder.worktreeBranchPrefixProperty, hasIncludeFiles: !!repoFolder.worktreeIncludeFilesProperty, hasBranchTrack: !!repoFolder.worktreeBranchTrackProperty, hasCreateNewBranch: !!repoFolder.worktreeCreateNewBranchProperty },
|
||||
noCommits: { enum: noCommits.isolationProperty.protocol.enum, value: noCommits.isolationValue, branch: noCommits.branchProperty, prefix: noCommits.worktreeBranchPrefixProperty, includeFiles: noCommits.worktreeIncludeFilesProperty, branchTrack: noCommits.worktreeBranchTrackProperty, createNewBranch: noCommits.worktreeCreateNewBranchProperty },
|
||||
}, {
|
||||
noRepo: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined },
|
||||
repoWorktree: { enum: ['folder', 'worktree'], value: 'worktree', branchDefault: 'main', branchReadOnly: false, prefixReadOnly: true, includeFilesReadOnly: true, branchTrackReadOnly: true },
|
||||
noRepo: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined, createNewBranch: undefined },
|
||||
repoWorktree: { enum: ['folder', 'worktree'], value: 'worktree', branchDefault: 'main', branchReadOnly: false, prefixReadOnly: true, includeFilesReadOnly: true, branchTrackReadOnly: true, createNewBranchReadOnly: true },
|
||||
repoWorktreeSelected: { branchDefault: 'main', branchValue: 'feature', branchEnum: ['main'] },
|
||||
repoFolder: { value: 'folder', branchDefault: 'feature', branchReadOnly: true, hasPrefix: true, hasIncludeFiles: true, hasBranchTrack: true },
|
||||
noCommits: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined },
|
||||
repoFolder: { value: 'folder', branchDefault: 'feature', branchReadOnly: true, hasPrefix: true, hasIncludeFiles: true, hasBranchTrack: true, hasCreateNewBranch: true },
|
||||
noCommits: { enum: ['folder'], value: 'folder', branch: undefined, prefix: undefined, includeFiles: undefined, branchTrack: undefined, createNewBranch: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,6 +205,49 @@ suite('WorktreeIsolation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('checks out an existing selected branch and uses the default branch as the diff base', async () => {
|
||||
const gitService = createGitService();
|
||||
gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'origin/main' });
|
||||
const isolation = createIsolation(disposables, {
|
||||
gitService,
|
||||
branchNameGenerator: { generateBranchName: async () => { throw new Error('should not generate a branch'); } },
|
||||
});
|
||||
|
||||
const worktree = await isolation.resolveWorkingDirectory({
|
||||
sessionUri,
|
||||
sessionId,
|
||||
workingDirectory: repoRoot,
|
||||
config: {
|
||||
[SessionConfigKey.Isolation]: 'worktree',
|
||||
[SessionConfigKey.Branch]: 'feature',
|
||||
[SessionConfigKey.WorktreeBranchTrack]: true,
|
||||
[SessionConfigKey.WorktreeCreateNewBranch]: false,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepStrictEqual({
|
||||
worktree: worktree?.toString(),
|
||||
addWorktreeArgs: addWorktreeCalls.map(call => ({
|
||||
commitish: call.commitish,
|
||||
newBranchName: call.newBranchName,
|
||||
track: call.track,
|
||||
preferRemoteBranch: call.preferRemoteBranch,
|
||||
})),
|
||||
branchName: await db.getMetadata('copilot.worktree.branchName'),
|
||||
diffBaseBranch: await db.getMetadata('agentHost.diffBaseBranch'),
|
||||
}, {
|
||||
worktree: URI.joinPath(worktreesRoot, 'feature').toString(),
|
||||
addWorktreeArgs: [{
|
||||
commitish: 'feature',
|
||||
newBranchName: undefined,
|
||||
track: true,
|
||||
preferRemoteBranch: false,
|
||||
}],
|
||||
branchName: 'feature',
|
||||
diffBaseBranch: 'origin/main',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveWorkingDirectory creates a worktree, persists metadata, queues the announcement, and is idempotent', async () => {
|
||||
const isolation = createIsolation(disposables);
|
||||
const config = { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' };
|
||||
|
||||
@@ -22,6 +22,7 @@ import { autorun, observableValue } from '../../../base/common/observable.js';
|
||||
import { SessionIsMaximizedContext } from '../../common/contextkeys.js';
|
||||
import { AGENTS_CENTERED_CONTENT_MAX_WIDTH } from '../../common/layoutConstants.js';
|
||||
import { setActiveSessionContextKeys } from '../../services/sessions/common/sessionContextKeys.js';
|
||||
import { ISessionChangesStatsCache } from '../../services/sessions/common/sessionChangesStatsCache.js';
|
||||
import { applySessionViewThemeColors } from './sessionBarStyles.js';
|
||||
import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js';
|
||||
|
||||
@@ -89,6 +90,7 @@ export class SessionView extends Disposable implements ISerializableView {
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@IContextKeyService contextKeyService: IContextKeyService,
|
||||
@IThemeService private readonly themeService: IThemeService,
|
||||
@ISessionChangesStatsCache private readonly _changesStatsCache: ISessionChangesStatsCache,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -193,7 +195,7 @@ export class SessionView extends Disposable implements ISerializableView {
|
||||
// scoped service whenever the session's observable properties change.
|
||||
// Passing `undefined` resets the keys to their defaults.
|
||||
return autorun(reader => {
|
||||
setActiveSessionContextKeys(session, this._scopedContextKeyService, reader);
|
||||
setActiveSessionContextKeys(session, this._scopedContextKeyService, reader, this._changesStatsCache);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export const SessionIsReadContext = new RawContextKey<boolean>('sessionIsRead',
|
||||
export const SessionIsArchivedContext = new RawContextKey<boolean>('sessionIsArchived', false, localize('sessionIsArchived', "Whether the session in scope is archived/marked as done (the active session globally, or a specific session within an isolated component such as the session view or a context menu overlay)"));
|
||||
export const SessionIsActiveContext = new RawContextKey<boolean>('sessionIsActive', false, localize('sessionIsActive', "Whether the session in scope is in progress or needs input"));
|
||||
export const SessionHasChangesContext = new RawContextKey<boolean>('sessionHasChanges', false, localize('sessionHasChanges', "Whether the session view's session has pending changes (insertions or deletions)"));
|
||||
export const SessionHasCachedChangesContext = new RawContextKey<boolean>('sessionHasCachedChanges', false, localize('sessionHasCachedChanges', "Whether the session view's session has remembered changes from the last time its changes pill was shown, while it has not reported its own changes yet. Used to render the changes pill optimistically when a session opens"));
|
||||
export const SessionHasPullRequestContext = new RawContextKey<boolean>('sessionHasPullRequest', false, localize('sessionHasPullRequest', "Whether the session view's session is associated with a GitHub pull request"));
|
||||
export const SessionHasIssuesContext = new RawContextKey<boolean>('sessionHasIssues', false, localize('sessionHasIssues', "Whether the session view's session references at least one GitHub issue"));
|
||||
export const SessionHasWorkspaceContext = new RawContextKey<boolean>('sessionHasWorkspace', false, localize('sessionHasWorkspace', "Whether the session view's session has an associated workspace folder"));
|
||||
|
||||
@@ -27,10 +27,11 @@ import { DiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/d
|
||||
import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js';
|
||||
import { Menus } from '../../../browser/menus.js';
|
||||
import { ChatPillActionViewItem } from '../../../../workbench/browser/chatPills.js';
|
||||
import { IsQuickChatSessionContext, SessionHasChangesContext, SinglePaneLayoutEnabledContext } from '../../../common/contextkeys.js';
|
||||
import { IsQuickChatSessionContext, SessionHasCachedChangesContext, SessionHasChangesContext, SinglePaneLayoutEnabledContext } from '../../../common/contextkeys.js';
|
||||
import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js';
|
||||
import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js';
|
||||
import { SessionChangesetOperationScope } from '../../../services/sessions/common/session.js';
|
||||
import { ISessionChangesStatsCache, readSessionChangesStats } from '../../../services/sessions/common/sessionChangesStatsCache.js';
|
||||
import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js';
|
||||
import { IChangesViewService } from '../common/changesViewService.js';
|
||||
import { ChangesMultiDiffSourceResolver, SessionChangesReviewedFilesContext } from './changesMultiDiffSourceResolver.js';
|
||||
@@ -49,13 +50,14 @@ class ViewAllChangesAction extends Action2 {
|
||||
title: localize2('agentSessions.changes', 'Changes'),
|
||||
icon: Codicon.diffMultiple,
|
||||
f1: false,
|
||||
// Metadata pill rendered with live +/- counts.
|
||||
// Metadata pill rendered with live +/- counts, or the counts last shown
|
||||
// for the session while it has not reported its changes yet.
|
||||
menu: {
|
||||
id: Menus.SessionHeaderMeta,
|
||||
group: 'navigation',
|
||||
order: 0,
|
||||
when: ContextKeyExpr.and(
|
||||
SessionHasChangesContext,
|
||||
ContextKeyExpr.or(SessionHasChangesContext, SessionHasCachedChangesContext),
|
||||
ContextKeyExpr.or(IsQuickChatSessionContext.negate(), SinglePaneLayoutEnabledContext)
|
||||
)
|
||||
},
|
||||
@@ -240,6 +242,10 @@ interface IDiffStats {
|
||||
* session's {@link ISession.changesSummary} when available, falling back to aggregating the
|
||||
* changeset the provider marks as {@link ISessionChangeset.isDefault} (or the session's
|
||||
* top-level {@link IActiveSession.changes} when none is default).
|
||||
*
|
||||
* A session reports its changes late, so until it reports any the counts last shown for it
|
||||
* are taken from the {@link ISessionChangesStatsCache} — the pill is then already there,
|
||||
* with plausible counts, the moment the session opens.
|
||||
*/
|
||||
export class ViewAllChangesActionViewItem extends ChatPillActionViewItem {
|
||||
|
||||
@@ -249,6 +255,7 @@ export class ViewAllChangesActionViewItem extends ChatPillActionViewItem {
|
||||
action: MenuItemAction,
|
||||
options: IActionViewItemOptions,
|
||||
@ISessionContext sessionContext: ISessionContext,
|
||||
@ISessionChangesStatsCache changesStatsCache: ISessionChangesStatsCache,
|
||||
) {
|
||||
super(undefined, action, options);
|
||||
|
||||
@@ -257,33 +264,15 @@ export class ViewAllChangesActionViewItem extends ChatPillActionViewItem {
|
||||
const workspace = session?.workspace.read(reader);
|
||||
const branch = workspace?.folders[0]?.gitRepository?.branchName?.trim();
|
||||
|
||||
// Prefer the provider-supplied changes summary which reflects the
|
||||
// session's authoritative aggregate. Fall back to aggregating the
|
||||
// default changeset's changes when no summary is available.
|
||||
const changesSummary = session?.changesSummary?.read(reader);
|
||||
if (changesSummary) {
|
||||
return {
|
||||
branch,
|
||||
files: changesSummary.files,
|
||||
insertions: changesSummary.additions,
|
||||
deletions: changesSummary.deletions,
|
||||
} satisfies IDiffStats;
|
||||
}
|
||||
|
||||
const defaultChangeset = session?.changesets.read(reader)?.find(c => c.isDefault.read(reader));
|
||||
const changes = (defaultChangeset?.changes.read(reader) ?? session?.changes.read(reader)) ?? [];
|
||||
|
||||
let insertions = 0, deletions = 0;
|
||||
for (const change of changes) {
|
||||
insertions += change.insertions;
|
||||
deletions += change.deletions;
|
||||
}
|
||||
const stats = session
|
||||
? readSessionChangesStats(session, reader) ?? changesStatsCache.get(session.sessionId, reader)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
branch,
|
||||
files: changes.length,
|
||||
insertions,
|
||||
deletions,
|
||||
files: stats?.files ?? 0,
|
||||
insertions: stats?.insertions ?? 0,
|
||||
deletions: stats?.deletions ?? 0,
|
||||
} satisfies IDiffStats;
|
||||
});
|
||||
|
||||
@@ -351,6 +340,39 @@ class ViewAllChangesActionViewItemContribution extends Disposable implements IWo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the changes pill shown for each visible session so it can be rendered
|
||||
* optimistically the next time that session is opened, before the provider has
|
||||
* reported its changes. Recording sessions as they are shown (rather than from the
|
||||
* pill itself) also keeps the cache honest: a session that ends up without changes
|
||||
* drops its entry instead of keeping a stale pill.
|
||||
*/
|
||||
class SessionChangesStatsCacheContribution extends Disposable implements IWorkbenchContribution {
|
||||
|
||||
static readonly ID = 'workbench.contrib.sessions.changesStatsCache';
|
||||
|
||||
constructor(
|
||||
@ISessionsService sessionsService: ISessionsService,
|
||||
@ISessionChangesStatsCache changesStatsCache: ISessionChangesStatsCache,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._register(autorun(reader => {
|
||||
for (const session of sessionsService.visibleSessions.read(reader)) {
|
||||
// While the worktree is pending the reported changes belong to the
|
||||
// checkout the session was started from, not to the session.
|
||||
if (!session || session.worktreePending?.read(reader)) {
|
||||
continue;
|
||||
}
|
||||
const stats = readSessionChangesStats(session, reader);
|
||||
if (stats) {
|
||||
changesStatsCache.set(session.sessionId, stats);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Multi-diff source resolver
|
||||
|
||||
/**
|
||||
@@ -469,3 +491,4 @@ class ChangesetOperationsActionControllerContribution extends Disposable impleme
|
||||
registerWorkbenchContribution2(ChangesMultiDiffSourceResolverContribution.ID, ChangesMultiDiffSourceResolverContribution, WorkbenchPhase.BlockRestore);
|
||||
registerWorkbenchContribution2(ChangesetOperationsActionControllerContribution.ID, ChangesetOperationsActionControllerContribution, WorkbenchPhase.AfterRestored);
|
||||
registerWorkbenchContribution2(ViewAllChangesActionViewItemContribution.ID, ViewAllChangesActionViewItemContribution, WorkbenchPhase.AfterRestored);
|
||||
registerWorkbenchContribution2(SessionChangesStatsCacheContribution.ID, SessionChangesStatsCacheContribution, WorkbenchPhase.AfterRestored);
|
||||
|
||||
@@ -799,13 +799,18 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
|
||||
}));
|
||||
|
||||
const dictationFocusKey = SessionsChatInputHasDictationFocus.bindTo(inputScopedContextKeyService);
|
||||
// The composer is a chat input, so it carries the shared focus key that
|
||||
// chat input keybindings such as paste as text are scoped to.
|
||||
const inputHasFocusKey = ChatContextKeys.inputHasFocus.bindTo(inputScopedContextKeyService);
|
||||
this._register(this._editor.onDidFocusEditorWidget(() => {
|
||||
dictationFocusKey.set(true);
|
||||
inputHasFocusKey.set(true);
|
||||
activeDictationComposer = this;
|
||||
this._onDidFocus.fire();
|
||||
}));
|
||||
this._register(this._editor.onDidBlurEditorWidget(() => {
|
||||
dictationFocusKey.set(false);
|
||||
inputHasFocusKey.set(false);
|
||||
if (activeDictationComposer === this) {
|
||||
activeDictationComposer = undefined;
|
||||
}
|
||||
|
||||
@@ -6,20 +6,28 @@
|
||||
import { Codicon } from '../../../../base/common/codicons.js';
|
||||
import { MarkdownString } from '../../../../base/common/htmlContent.js';
|
||||
import { Disposable } from '../../../../base/common/lifecycle.js';
|
||||
import { getMediaMime } from '../../../../base/common/mime.js';
|
||||
import { derived, IObservable, IReader } from '../../../../base/common/observable.js';
|
||||
import { basename, getComparisonKey } from '../../../../base/common/resources.js';
|
||||
import { ThemeIcon } from '../../../../base/common/themables.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { generateUuid } from '../../../../base/common/uuid.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { toAction } from '../../../../base/common/actions.js';
|
||||
import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js';
|
||||
import { ICommandService } from '../../../../platform/commands/common/commands.js';
|
||||
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
|
||||
import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js';
|
||||
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
|
||||
import type { IChatPillEntry, IChatPillSection } from '../../../../workbench/browser/chatPills.js';
|
||||
import { openChatTurnFile, previewKind } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js';
|
||||
import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js';
|
||||
import type { IImageCarouselCollection } from '../../../../workbench/contrib/imageCarousel/browser/imageCarouselTypes.js';
|
||||
import { SessionArtifactKind, SessionFileOperation, type ISessionArtifact, type ISessionFile } from '../../../services/sessions/common/session.js';
|
||||
import type { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js';
|
||||
|
||||
const OPEN_IMAGE_CAROUSEL_COMMAND_ID = 'workbench.action.chat.openImageInCarousel';
|
||||
|
||||
const artifactIcons: ReadonlyMap<SessionArtifactKind, ThemeIcon> = new Map([
|
||||
[SessionArtifactKind.PullRequest, Codicon.gitPullRequest],
|
||||
[SessionArtifactKind.Issue, Codicon.issues],
|
||||
@@ -42,9 +50,15 @@ const sectionOrder: readonly { readonly kind: SessionArtifactKind; readonly titl
|
||||
export interface ISessionArtifactActions {
|
||||
openExternal(link: URI): void;
|
||||
openResource(uri: URI): void;
|
||||
openImages(images: readonly ISessionArtifactImage[], startIndex: number): void;
|
||||
copy(text: string): void;
|
||||
}
|
||||
|
||||
export interface ISessionArtifactImage {
|
||||
readonly uri: URI;
|
||||
readonly mimeType: string;
|
||||
}
|
||||
|
||||
function artifactValueKey(artifact: ISessionArtifact): string {
|
||||
if (artifact.uri) {
|
||||
return getComparisonKey(artifact.uri);
|
||||
@@ -52,7 +66,12 @@ function artifactValueKey(artifact: ISessionArtifact): string {
|
||||
return (artifact.link?.toString() ?? artifact.commitHash ?? artifact.id).toLowerCase();
|
||||
}
|
||||
|
||||
function artifactLocation(uri: URI, label: string): Pick<IChatPillEntry, 'ariaDescription' | 'ariaLabel' | 'hover' | 'tooltip'> {
|
||||
/**
|
||||
* The location details shown for an artifact: its URI/link as the hover beside
|
||||
* the dropdown row, the plain-text screen reader description, and the tooltip,
|
||||
* while the accessible name stays the action the entry performs.
|
||||
*/
|
||||
export function sessionArtifactLocation(uri: URI, label: string): Pick<IChatPillEntry, 'ariaDescription' | 'ariaLabel' | 'hover' | 'tooltip'> {
|
||||
const value = uri.toString(true);
|
||||
return {
|
||||
ariaDescription: value,
|
||||
@@ -62,6 +81,11 @@ function artifactLocation(uri: URI, label: string): Pick<IChatPillEntry, 'ariaDe
|
||||
};
|
||||
}
|
||||
|
||||
function getImageMimeType(uri: URI): string | undefined {
|
||||
const mimeType = getMediaMime(uri.path);
|
||||
return mimeType?.startsWith('image/') ? mimeType : undefined;
|
||||
}
|
||||
|
||||
function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions): IChatPillEntry | undefined {
|
||||
if (artifact.kind === SessionArtifactKind.File) {
|
||||
if (!artifact.uri) {
|
||||
@@ -69,7 +93,7 @@ function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions):
|
||||
}
|
||||
const uri = artifact.uri;
|
||||
const label = basename(uri);
|
||||
return { id: artifact.id, label, resource: uri, ...artifactLocation(uri, label), open: () => actions.openResource(uri) };
|
||||
return { id: artifact.id, label, resource: uri, ...sessionArtifactLocation(uri, label), open: () => actions.openResource(uri) };
|
||||
}
|
||||
|
||||
const icon = artifactIcons.get(artifact.kind) ?? Codicon.archive;
|
||||
@@ -86,7 +110,7 @@ function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions):
|
||||
run: () => actions.copy(artifact.commitHash!),
|
||||
})]
|
||||
: [];
|
||||
return { id: artifact.id, label: artifact.label, icon, toolbarActions: copyAction, ...artifactLocation(link, artifact.label), open: () => actions.openExternal(link) };
|
||||
return { id: artifact.id, label: artifact.label, icon, toolbarActions: copyAction, ...sessionArtifactLocation(link, artifact.label), open: () => actions.openExternal(link) };
|
||||
}
|
||||
|
||||
if (artifact.kind === SessionArtifactKind.Resource) {
|
||||
@@ -94,14 +118,14 @@ function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions):
|
||||
return undefined;
|
||||
}
|
||||
const uri = artifact.uri;
|
||||
return { id: artifact.id, label: artifact.label, icon, ...artifactLocation(uri, artifact.label), open: () => actions.openResource(uri) };
|
||||
return { id: artifact.id, label: artifact.label, icon, ...sessionArtifactLocation(uri, artifact.label), open: () => actions.openResource(uri) };
|
||||
}
|
||||
|
||||
if (!artifact.link) {
|
||||
return undefined;
|
||||
}
|
||||
const link = artifact.link;
|
||||
return { id: artifact.id, label: artifact.label, icon, ...artifactLocation(link, artifact.label), open: () => actions.openExternal(link) };
|
||||
return { id: artifact.id, label: artifact.label, icon, ...sessionArtifactLocation(link, artifact.label), open: () => actions.openExternal(link) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,11 +133,20 @@ function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions):
|
||||
* the previewable files the session wrote outside its workspace, de-duplicated
|
||||
* with the agent's own entries winning.
|
||||
*/
|
||||
export function buildSessionArtifactSections(artifacts: readonly ISessionArtifact[], externalFiles: readonly ISessionFile[], actions: ISessionArtifactActions): readonly IChatPillSection[] {
|
||||
export function buildSessionArtifactSections(artifacts: readonly ISessionArtifact[], externalFiles: readonly ISessionFile[], actions: ISessionArtifactActions, imageCarouselEnabled: boolean): readonly IChatPillSection[] {
|
||||
const entriesByKind = new Map<SessionArtifactKind, IChatPillEntry[]>();
|
||||
const images: ISessionArtifactImage[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
const imageMimeType = artifact.uri ? getImageMimeType(artifact.uri) : undefined;
|
||||
if (artifact.kind === SessionArtifactKind.File && artifact.uri && imageMimeType) {
|
||||
if (!seen.has(artifactValueKey(artifact))) {
|
||||
seen.add(artifactValueKey(artifact));
|
||||
images.push({ uri: artifact.uri, mimeType: imageMimeType });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const entry = toEntry(artifact, actions);
|
||||
if (!entry || seen.has(artifactValueKey(artifact))) {
|
||||
continue;
|
||||
@@ -125,18 +158,43 @@ export function buildSessionArtifactSections(artifacts: readonly ISessionArtifac
|
||||
}
|
||||
|
||||
for (const file of externalFiles) {
|
||||
if (file.operation === SessionFileOperation.Deleted || !previewKind(file.uri) || seen.has(getComparisonKey(file.uri))) {
|
||||
const imageMimeType = getImageMimeType(file.uri);
|
||||
if (file.operation === SessionFileOperation.Deleted || (!previewKind(file.uri) && !imageMimeType) || seen.has(getComparisonKey(file.uri))) {
|
||||
continue;
|
||||
}
|
||||
seen.add(getComparisonKey(file.uri));
|
||||
if (imageMimeType) {
|
||||
images.push({ uri: file.uri, mimeType: imageMimeType });
|
||||
continue;
|
||||
}
|
||||
const entries = entriesByKind.get(SessionArtifactKind.File) ?? [];
|
||||
const label = basename(file.uri);
|
||||
entries.push({ id: file.uri.toString(), label, resource: file.uri, ...artifactLocation(file.uri, label), open: () => actions.openResource(file.uri) });
|
||||
entries.push({ id: file.uri.toString(), label, resource: file.uri, ...sessionArtifactLocation(file.uri, label), open: () => actions.openResource(file.uri) });
|
||||
entriesByKind.set(SessionArtifactKind.File, entries);
|
||||
}
|
||||
|
||||
const sections: IChatPillSection[] = [];
|
||||
for (const { kind, title } of sectionOrder) {
|
||||
if (kind === SessionArtifactKind.File && images.length) {
|
||||
sections.push({
|
||||
title: localize('sessionArtifacts.images', "Images"),
|
||||
entries: images.map(({ uri }, index) => {
|
||||
const label = basename(uri);
|
||||
return {
|
||||
id: uri.toString(),
|
||||
label,
|
||||
resource: uri,
|
||||
...sessionArtifactLocation(uri, label),
|
||||
...(imageCarouselEnabled
|
||||
? {
|
||||
ariaLabel: localize('sessionArtifacts.openImage', "Open {0} in Images Preview", label),
|
||||
open: () => actions.openImages(images, index),
|
||||
}
|
||||
: { open: () => actions.openResource(uri) }),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
const entries = entriesByKind.get(kind);
|
||||
if (entries?.length) {
|
||||
sections.push({ title, entries });
|
||||
@@ -153,11 +211,14 @@ export class SessionArtifacts extends Disposable {
|
||||
constructor(
|
||||
session: IObservable<IActiveSession | undefined>,
|
||||
@IClipboardService private readonly _clipboardService: IClipboardService,
|
||||
@ICommandService private readonly _commandService: ICommandService,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@IOpenerService private readonly _openerService: IOpenerService,
|
||||
) {
|
||||
super();
|
||||
|
||||
const imageCarouselEnabled = observableConfigValue<boolean>(ChatConfiguration.ImageCarouselEnabled, true, this._configurationService);
|
||||
|
||||
this.sections = derived(this, reader => {
|
||||
const current = session.read(reader);
|
||||
if (!current) {
|
||||
@@ -167,6 +228,7 @@ export class SessionArtifacts extends Disposable {
|
||||
current.artifacts?.read(reader) ?? [],
|
||||
this._readExternalFiles(current, reader),
|
||||
this._actions(),
|
||||
imageCarouselEnabled.read(reader),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -185,6 +247,22 @@ export class SessionArtifacts extends Disposable {
|
||||
}
|
||||
void this._openerService.open(uri, { fromUserGesture: true });
|
||||
},
|
||||
openImages: (images, startIndex) => {
|
||||
const collection: IImageCarouselCollection = {
|
||||
id: generateUuid(),
|
||||
title: localize('sessionArtifacts.imageCarouselTitle', "Artifact Images"),
|
||||
sections: [{
|
||||
title: localize('sessionArtifacts.images', "Images"),
|
||||
images: images.map(image => ({
|
||||
id: image.uri.toString(),
|
||||
name: basename(image.uri),
|
||||
mimeType: image.mimeType,
|
||||
uri: image.uri,
|
||||
})),
|
||||
}],
|
||||
};
|
||||
void this._commandService.executeCommand(OPEN_IMAGE_CAROUSEL_COMMAND_ID, { collection, startIndex });
|
||||
},
|
||||
copy: text => { void this._clipboardService.writeText(text); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { $, addDisposableListener, DisposableResizeObserver, EventType, getWindo
|
||||
import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js';
|
||||
import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js';
|
||||
import { toAction, Action, Separator, type IAction } from '../../../../base/common/actions.js';
|
||||
import { MarkdownString } from '../../../../base/common/htmlContent.js';
|
||||
import { Disposable } from '../../../../base/common/lifecycle.js';
|
||||
import { autorun, derived, derivedOpts, IObservable, IReader, observableValue } from '../../../../base/common/observable.js';
|
||||
import { isEqual } from '../../../../base/common/resources.js';
|
||||
@@ -18,7 +17,7 @@ import { IContextMenuService } from '../../../../platform/contextview/browser/co
|
||||
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js';
|
||||
import { CHAT_TURN_ARTIFACT_PILL_ID, CHAT_TURN_CHANGES_PILL_ID, ChatTurnPillsProvider, diffStatsEqual, EMPTY_DIFF_STATS, IChatTurnPillsModel, IDiffStats, observeTurnStatusPillsEnabled } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js';
|
||||
import { SessionArtifacts } from './sessionArtifacts.js';
|
||||
import { SessionArtifacts, sessionArtifactLocation } from './sessionArtifacts.js';
|
||||
import { chatCustomizationPillOptions, SessionCustomizations, SESSION_CUSTOMIZATIONS_PILL_ID } from './sessionCustomizations.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { getChatPillEntries, ChatPillsWidget, IChatPill, IChatPillsModel, type IChatPillSection } from '../../../../workbench/browser/chatPills.js';
|
||||
@@ -54,17 +53,7 @@ function computeTurnStats(chat: IChat, reader: IReader): IDiffStats {
|
||||
function buildDebugArtifactSections(debugData: ISessionChatPillsDebugData): readonly IChatPillSection[] {
|
||||
const entries = debugData.markdownFiles.map(name => {
|
||||
const resource = URI.from({ scheme: 'session-chat-pills-debug', path: `/${name}` });
|
||||
const location = resource.toString(true);
|
||||
return {
|
||||
id: name,
|
||||
label: name,
|
||||
resource,
|
||||
ariaDescription: location,
|
||||
ariaLabel: localize('sessionArtifacts.open', "Open {0}", name),
|
||||
hover: { content: new MarkdownString().appendText(location) },
|
||||
tooltip: location,
|
||||
open: () => { },
|
||||
};
|
||||
return { id: name, label: name, resource, ...sessionArtifactLocation(resource, name), open: () => { } };
|
||||
});
|
||||
return entries.length ? [{ title: localize('sessionArtifacts.files', "Files"), entries }] : [];
|
||||
}
|
||||
@@ -240,7 +229,7 @@ export class SessionChatInputToolbar extends Disposable {
|
||||
pills.element.classList.add('show-file-icons');
|
||||
this._content.appendChild(pills.element);
|
||||
|
||||
// Kinds the session reports data for; the others cannot be toggled.
|
||||
// Kinds the session reports data for; the others are listed in a separate group.
|
||||
const kindsWithData = derived(reader => {
|
||||
const kinds = new Set<SessionChatPillKind>();
|
||||
for (const pill of candidatePills.read(reader)) {
|
||||
@@ -281,7 +270,6 @@ export class SessionChatInputToolbar extends Disposable {
|
||||
id: `sessions.chatPills.toggle.${entry.kind}`,
|
||||
label: entry.label,
|
||||
checked: entry.checked,
|
||||
enabled: entry.enabled,
|
||||
run: () => visibility.toggle(entry.kind),
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Menus } from '../../../browser/menus.js';
|
||||
import { ISessionContext, SessionContext } from '../../../services/sessions/browser/sessionContext.js';
|
||||
import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js';
|
||||
import { setSessionContextKeys } from '../../../services/sessions/common/sessionContextKeys.js';
|
||||
import { ISessionChangesStatsCache } from '../../../services/sessions/common/sessionChangesStatsCache.js';
|
||||
|
||||
/** Adapts the session metadata menu to observable chat-pill descriptors. */
|
||||
export class SessionMetadataPills extends Disposable {
|
||||
@@ -33,6 +34,7 @@ export class SessionMetadataPills extends Disposable {
|
||||
@IContextKeyService contextKeyService: IContextKeyService,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@IMenuService menuService: IMenuService,
|
||||
@ISessionChangesStatsCache changesStatsCache: ISessionChangesStatsCache,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -43,7 +45,7 @@ export class SessionMetadataPills extends Disposable {
|
||||
)));
|
||||
|
||||
this._register(autorun(reader => {
|
||||
setSessionContextKeys(session.read(reader), scopedContextKeyService, reader);
|
||||
setSessionContextKeys(session.read(reader), scopedContextKeyService, reader, changesStatsCache);
|
||||
}));
|
||||
|
||||
const menu = this._register(menuService.createMenu(Menus.SessionHeaderMeta, scopedContextKeyService, { emitEventsForSubmenuChanges: true }));
|
||||
|
||||
@@ -56,6 +56,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat
|
||||
content.push(localize('sessionsChat.micContextMenu', "To choose a microphone or turn off dictation or Voice Mode, focus the microphone button in the input toolbar and open its context menu (for example Shift+F10)."));
|
||||
content.push(localize('sessionsChat.contextReferences', "Type # in the chat input to attach context. Use #file to reference a file or folder, or #session to reference another agent session. Referencing a session together with the /troubleshoot command analyzes that session's logs instead of the current one. Accept a suggestion with Tab or Enter; the reference appears as a pill above the input that you can remove."));
|
||||
content.push(localize('sessionsChat.pastedText', "Long pasted text is stored as an attached text item and replaced in the input with a numbered inline reference."));
|
||||
content.push(localize('sessionsChat.pasteAsText', "To paste the clipboard as plain text, without converting it to Markdown or storing it as an attachment, invoke Paste as Text{0}.", '<keybinding:editor.action.pasteAsText>'));
|
||||
content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach metadata and status pills above it, then press Enter or Space to activate a pill. Live browsers appear in their own pill, and background activities such as running subagents in another. A pill with more than one entry opens a picker; use the up and down arrows to navigate, Enter to open an entry, and Escape to dismiss the picker and return focus to the pill."));
|
||||
content.push(localize('sessionsChat.conversations', "When multiple chats appear as tabs in a single group, the tab row replaces the session header and includes the session actions. Side-by-side chat groups retain the session header and keep their tab rows compact. Activate New Chat at the end of a tab row to start another chat in that group."));
|
||||
content.push(localize('sessionsChat.subagentPills', "Subagent pills in the chat transcript can be dragged to a chat group's edge to open the subagent beside the current chat. With the keyboard, focus a subagent pill and press Alt+Enter to open it beside the current chat."));
|
||||
|
||||
@@ -57,8 +57,6 @@ export interface ISessionChatPillMenuEntry {
|
||||
readonly label: string;
|
||||
/** Whether the pill shows when it has data. */
|
||||
readonly checked: boolean;
|
||||
/** Kinds without data cannot be toggled. */
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,8 +71,8 @@ export interface ISessionChatPillMenu {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the visibility menu. Every hideable kind is listed, checked while it is
|
||||
* not hidden, and disabled while the session reports no data for it.
|
||||
* Builds the visibility menu. Every hideable kind is listed and toggleable,
|
||||
* checked while it is not hidden, grouped by whether the session has data for it.
|
||||
*
|
||||
* @param targetKind The pill that was right-clicked, which gains a "Hide X"
|
||||
* entry. Omitted when the click did not land on a pill.
|
||||
@@ -90,12 +88,10 @@ export function getSessionChatPillMenu(
|
||||
if (!isSessionChatPillHideable(kind)) {
|
||||
continue;
|
||||
}
|
||||
const enabled = kindsWithData.has(kind);
|
||||
(enabled ? withData : withoutData).push({
|
||||
(kindsWithData.has(kind) ? withData : withoutData).push({
|
||||
kind,
|
||||
label: getSessionChatPillLabel(kind),
|
||||
checked: !hiddenKinds.has(kind),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,9 @@ import { createTextModel } from '../../../../../editor/test/common/testTextModel
|
||||
import { withTestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js';
|
||||
import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';
|
||||
import { ILogService } from '../../../../../platform/log/common/log.js';
|
||||
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
|
||||
import { IChatPasteTarget, IChatPasteTargetService } from '../../../../../workbench/contrib/chat/browser/chat.js';
|
||||
import { PasteTextProvider } from '../../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js';
|
||||
import { PasteTextProvider, pastedTextArtifactDefaultMinLength } from '../../../../../workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.js';
|
||||
import { IChatRequestVariableEntry, isPastedTextArtifact } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js';
|
||||
import { IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js';
|
||||
import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js';
|
||||
@@ -113,6 +114,9 @@ suite('NewChatInputPasteTarget', () => {
|
||||
pasteTargetService,
|
||||
new class extends mock<IModelService>() { },
|
||||
new class extends mock<ILogService>() { },
|
||||
new class extends mock<IConfigurationService>() {
|
||||
override getValue<T>(): T { return pastedTextArtifactDefaultMinLength as T; }
|
||||
},
|
||||
);
|
||||
|
||||
const transfer = new VSDataTransfer();
|
||||
@@ -167,7 +171,7 @@ suite('NewChatInputPasteTarget', () => {
|
||||
}
|
||||
|
||||
test('keeps the attachment and its inline reference consistent across undo and redo', async () => {
|
||||
const pastedText = 'x'.repeat(1200);
|
||||
const pastedText = `${'x'.repeat(1200)}\n`.repeat(10);
|
||||
const snapshots = await runPasteLifecycle(pastedText);
|
||||
|
||||
const attached = { attachments: ['Pasted text #1'], codeIsPreserved: true, sent: [{ name: 'Pasted text #1', text: '#attachment:Pasted text #1' }] };
|
||||
@@ -188,7 +192,7 @@ suite('NewChatInputPasteTarget', () => {
|
||||
});
|
||||
|
||||
test('removing the attachment takes its inline reference out of the input', async () => {
|
||||
const pastedText = 'x'.repeat(1200);
|
||||
const pastedText = `${'x'.repeat(1200)}\n`.repeat(10);
|
||||
const snapshots = await runPasteLifecycle(pastedText, attachments => {
|
||||
attachments.removeAttachment(attachments.attachments[0].id);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import assert from 'assert';
|
||||
import { isMarkdownString } from '../../../../../base/common/htmlContent.js';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { buildSessionArtifactSections, type ISessionArtifactActions } from '../../browser/sessionArtifacts.js';
|
||||
import { buildSessionArtifactSections, type ISessionArtifactActions, type ISessionArtifactImage } from '../../browser/sessionArtifacts.js';
|
||||
import { type ISessionArtifact, SessionArtifactKind, SessionFileOperation } from '../../../../services/sessions/common/session.js';
|
||||
|
||||
suite('Session Artifacts', () => {
|
||||
@@ -16,6 +16,7 @@ suite('Session Artifacts', () => {
|
||||
const actions: ISessionArtifactActions = {
|
||||
openExternal() { },
|
||||
openResource() { },
|
||||
openImages() { },
|
||||
copy() { },
|
||||
};
|
||||
|
||||
@@ -30,7 +31,7 @@ suite('Session Artifacts', () => {
|
||||
{ id: 'resource', kind: SessionArtifactKind.Resource, label: 'Resource', uri: resourceUri },
|
||||
];
|
||||
|
||||
const entries = buildSessionArtifactSections(artifacts, [{ uri: externalFileUri, operation: SessionFileOperation.Created }], actions).flatMap(section => section.entries);
|
||||
const entries = buildSessionArtifactSections(artifacts, [{ uri: externalFileUri, operation: SessionFileOperation.Created }], actions, true).flatMap(section => section.entries);
|
||||
assert.deepStrictEqual(entries.map(entry => {
|
||||
const content = entry.hover?.content;
|
||||
return {
|
||||
@@ -47,4 +48,63 @@ suite('Session Artifacts', () => {
|
||||
{ label: 'Resource', ariaLabel: 'Open Resource', ariaDescription: resourceUri.toString(true), hover: resourceUri.toString(true), tooltip: resourceUri.toString(true) },
|
||||
]);
|
||||
});
|
||||
|
||||
test('groups artifact images separately and opens all images in the carousel', () => {
|
||||
const screenshotUri = URI.file('/artifacts/screenshot.png');
|
||||
const diagramUri = URI.file('/external/diagram.jpg');
|
||||
const reportUri = URI.file('/artifacts/report.md');
|
||||
const opened: { images: readonly ISessionArtifactImage[]; startIndex: number }[] = [];
|
||||
const imageActions: ISessionArtifactActions = {
|
||||
...actions,
|
||||
openImages: (images, startIndex) => opened.push({ images, startIndex }),
|
||||
};
|
||||
const artifacts: readonly ISessionArtifact[] = [
|
||||
{ id: 'screenshot', kind: SessionArtifactKind.File, label: 'Screenshot', uri: screenshotUri },
|
||||
{ id: 'report', kind: SessionArtifactKind.File, label: 'Report', uri: reportUri },
|
||||
];
|
||||
|
||||
const sections = buildSessionArtifactSections(artifacts, [
|
||||
{ uri: diagramUri, operation: SessionFileOperation.Created },
|
||||
], imageActions, true);
|
||||
const imageSection = sections.find(section => section.title === 'Images');
|
||||
assert.ok(imageSection);
|
||||
imageSection.entries[1].open();
|
||||
|
||||
assert.deepStrictEqual({
|
||||
sections: sections.map(section => ({ title: section.title, labels: section.entries.map(entry => entry.label) })),
|
||||
opened: opened.map(entry => ({ images: entry.images.map(image => image.uri.path), startIndex: entry.startIndex })),
|
||||
}, {
|
||||
sections: [
|
||||
{ title: 'Images', labels: ['screenshot.png', 'diagram.jpg'] },
|
||||
{ title: 'Files', labels: ['report.md'] },
|
||||
],
|
||||
opened: [{ images: ['/artifacts/screenshot.png', '/external/diagram.jpg'], startIndex: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
test('opens the image resource when the image carousel is disabled', () => {
|
||||
const screenshotUri = URI.file('/artifacts/screenshot.png');
|
||||
const opened: string[] = [];
|
||||
const imageActions: ISessionArtifactActions = {
|
||||
...actions,
|
||||
openImages: () => opened.push('carousel'),
|
||||
openResource: uri => opened.push(uri.path),
|
||||
};
|
||||
const artifacts: readonly ISessionArtifact[] = [
|
||||
{ id: 'screenshot', kind: SessionArtifactKind.File, label: 'Screenshot', uri: screenshotUri },
|
||||
];
|
||||
|
||||
const sections = buildSessionArtifactSections(artifacts, [], imageActions, false);
|
||||
const imageSection = sections.find(section => section.title === 'Images');
|
||||
assert.ok(imageSection);
|
||||
imageSection.entries[0].open();
|
||||
|
||||
assert.deepStrictEqual({
|
||||
ariaLabel: imageSection.entries[0].ariaLabel,
|
||||
opened,
|
||||
}, {
|
||||
ariaLabel: 'Open screenshot.png',
|
||||
opened: ['/artifacts/screenshot.png'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,14 +19,14 @@ suite('SessionChatPills', () => {
|
||||
|
||||
assert.deepStrictEqual(menu, {
|
||||
withData: [
|
||||
{ kind: SessionChatPillKind.PullRequests, label: 'Pull Requests', checked: false, enabled: true },
|
||||
{ kind: SessionChatPillKind.Subagents, label: 'Subagents', checked: true, enabled: true },
|
||||
{ kind: SessionChatPillKind.PullRequests, label: 'Pull Requests', checked: false },
|
||||
{ kind: SessionChatPillKind.Subagents, label: 'Subagents', checked: true },
|
||||
],
|
||||
withoutData: [
|
||||
{ kind: SessionChatPillKind.Artifacts, label: 'Artifacts', checked: true, enabled: false },
|
||||
{ kind: SessionChatPillKind.Customizations, label: 'Customizations', checked: true, enabled: false },
|
||||
{ kind: SessionChatPillKind.Issues, label: 'Issues', checked: true, enabled: false },
|
||||
{ kind: SessionChatPillKind.Browsers, label: 'Browsers', checked: true, enabled: false },
|
||||
{ kind: SessionChatPillKind.Artifacts, label: 'Artifacts', checked: true },
|
||||
{ kind: SessionChatPillKind.Customizations, label: 'Customizations', checked: true },
|
||||
{ kind: SessionChatPillKind.Issues, label: 'Issues', checked: true },
|
||||
{ kind: SessionChatPillKind.Browsers, label: 'Browsers', checked: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ import { Menus } from '../../../browser/menus.js';
|
||||
import { ISessionSection, SessionSectionHasGitHubRepositoryContext, SessionSectionHasNonCloudRepositoryContext, SessionSectionTypeContext } from '../../sessions/browser/views/sessionsList.js';
|
||||
import { IGitHubService } from './githubService.js';
|
||||
import { IGitHubPullRequestSummary } from '../common/types.js';
|
||||
import { createPullRequestBootstrapPrompt, createPullRequestContextAttachment, createPullRequestQuickPickItems, createPullRequestSessionMetadata, getExistingPullRequests, hasExistingPullRequest, IPullRequestQuickPickItem, mergePullRequestSummaries, pullRequestMatchesQuery, resolvePullRequestSessionRepository } from './pullRequestPicker.js';
|
||||
import { createPullRequestBootstrapPrompt, createPullRequestContextAttachment, createPullRequestQuickPickItems, createPullRequestSessionMetadata, getExistingPullRequests, IPullRequestQuickPickItem, isPullRequestAvailable, mergePullRequestSummaries, pullRequestMatchesQuery, resolvePullRequestSessionRepository } from './pullRequestPicker.js';
|
||||
import { createAndOpenPullRequestSession } from './pullRequestSessionCreation.js';
|
||||
|
||||
export const NEW_SESSION_FROM_PULL_REQUEST_COMMAND_ID = 'workbench.agentSessions.newSessionFromPullRequest';
|
||||
@@ -192,7 +192,7 @@ registerAction2(class NewSessionFromPullRequestAction extends Action2 {
|
||||
const initialGroupsPromise = loadInitialGroups();
|
||||
const loadUntilMatch = async (query: string, generation: number): Promise<void> => {
|
||||
await initialGroupsPromise;
|
||||
while (generation === searchGeneration && query && hasNextPage && !pullRequests.some(pullRequest => !hasExistingPullRequest(pullRequest, existingPullRequests) && pullRequestMatchesQuery(pullRequest, query))) {
|
||||
while (generation === searchGeneration && query && hasNextPage && !pullRequests.some(pullRequest => isPullRequestAvailable(pullRequest, existingPullRequests) && pullRequestMatchesQuery(pullRequest, query))) {
|
||||
await loadNextPage();
|
||||
}
|
||||
};
|
||||
@@ -231,8 +231,9 @@ registerAction2(class NewSessionFromPullRequestAction extends Action2 {
|
||||
},
|
||||
}, {
|
||||
isolationMode: 'worktree',
|
||||
branch: pullRequest.checkoutRef,
|
||||
branch: pullRequest.headRef,
|
||||
worktreeBranchTrack: true,
|
||||
worktreeCreateNewBranch: false,
|
||||
metadata: createPullRequestSessionMetadata(repository.owner, repository.repo, pullRequest),
|
||||
onSessionCreated,
|
||||
}, pickerCts.token),
|
||||
|
||||
@@ -23,6 +23,7 @@ interface IGitHubPullRequestNode {
|
||||
readonly title: string;
|
||||
readonly author: { readonly login: string; readonly avatarUrl: string } | null;
|
||||
readonly headRefName: string;
|
||||
readonly isCrossRepository: boolean;
|
||||
readonly isDraft: boolean;
|
||||
readonly updatedAt: string;
|
||||
readonly additions: number;
|
||||
@@ -42,6 +43,7 @@ const LIST_PULL_REQUESTS_QUERY = [
|
||||
' title',
|
||||
' author { login avatarUrl }',
|
||||
' headRefName',
|
||||
' isCrossRepository',
|
||||
' isDraft',
|
||||
' updatedAt',
|
||||
' additions',
|
||||
@@ -62,6 +64,7 @@ const LIST_PULL_REQUEST_NUMBERS_QUERY = [
|
||||
' title',
|
||||
' author { login avatarUrl }',
|
||||
' headRefName',
|
||||
' isCrossRepository',
|
||||
' isDraft',
|
||||
' updatedAt',
|
||||
' additions',
|
||||
@@ -123,6 +126,7 @@ function mapPullRequest(pullRequest: IGitHubPullRequestNode, reviewRequestedFrom
|
||||
author: pullRequest.author ?? { login: 'ghost', avatarUrl: '' },
|
||||
headRef: pullRequest.headRefName,
|
||||
checkoutRef: `refs/pull/${pullRequest.number}/head`,
|
||||
isCrossRepository: pullRequest.isCrossRepository,
|
||||
isDraft: pullRequest.isDraft,
|
||||
updatedAt: pullRequest.updatedAt,
|
||||
additions: pullRequest.additions,
|
||||
|
||||
@@ -111,8 +111,12 @@ export function hasExistingPullRequest(pullRequest: IGitHubPullRequestSummary, e
|
||||
return existingPullRequests.numbers.has(pullRequest.number) || existingPullRequests.headRefs.has(pullRequest.headRef);
|
||||
}
|
||||
|
||||
export function isPullRequestAvailable(pullRequest: IGitHubPullRequestSummary, existingPullRequests: IExistingPullRequests): boolean {
|
||||
return !pullRequest.isCrossRepository && !hasExistingPullRequest(pullRequest, existingPullRequests);
|
||||
}
|
||||
|
||||
export function createPullRequestQuickPickItems(pullRequests: readonly IGitHubPullRequestSummary[], existingPullRequests: IExistingPullRequests): readonly (IPullRequestQuickPickItem | IQuickPickSeparator)[] {
|
||||
const available = pullRequests.filter(pullRequest => !hasExistingPullRequest(pullRequest, existingPullRequests));
|
||||
const available = pullRequests.filter(pullRequest => isPullRequestAvailable(pullRequest, existingPullRequests));
|
||||
const waitingForReview = available.filter(pullRequest => pullRequest.reviewRequestedFromViewer);
|
||||
const assigned = available.filter(pullRequest => !pullRequest.reviewRequestedFromViewer && pullRequest.assignedToViewer);
|
||||
const other = available.filter(pullRequest => !pullRequest.reviewRequestedFromViewer && !pullRequest.assignedToViewer);
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface IGitHubPullRequestSummary {
|
||||
readonly author: IGitHubUser;
|
||||
readonly headRef: string;
|
||||
readonly checkoutRef: string;
|
||||
readonly isCrossRepository: boolean;
|
||||
readonly isDraft: boolean;
|
||||
readonly updatedAt: string;
|
||||
readonly additions: number;
|
||||
|
||||
@@ -450,6 +450,7 @@ suite('GitHubPullRequestsFetcher', () => {
|
||||
title: 'Improve sessions',
|
||||
author: { login: 'author', avatarUrl: 'avatar' },
|
||||
headRefName: 'feature',
|
||||
isCrossRepository: false,
|
||||
isDraft: true,
|
||||
updatedAt: '2026-07-30T12:00:00Z',
|
||||
additions: 12,
|
||||
@@ -470,6 +471,7 @@ suite('GitHubPullRequestsFetcher', () => {
|
||||
author: { login: 'author', avatarUrl: 'avatar' },
|
||||
headRef: 'feature',
|
||||
checkoutRef: 'refs/pull/7/head',
|
||||
isCrossRepository: false,
|
||||
isDraft: true,
|
||||
updatedAt: '2026-07-30T12:00:00Z',
|
||||
additions: 12,
|
||||
@@ -657,6 +659,7 @@ function makePullRequestSearchNode(number: number): unknown {
|
||||
title: `Pull request ${number}`,
|
||||
author: { login: 'author', avatarUrl: '' },
|
||||
headRefName: `feature-${number}`,
|
||||
isCrossRepository: false,
|
||||
isDraft: false,
|
||||
updatedAt: '2026-07-30T12:00:00Z',
|
||||
additions: number,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { mock } from '../../../../../base/test/common/mock.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { readSessionGitHubState } from '../../../../../platform/agentHost/common/state/sessionState.js';
|
||||
import { ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js';
|
||||
import { createPullRequestBootstrapPrompt, createPullRequestContextAttachment, createPullRequestQuickPickItems, createPullRequestSessionMetadata, getExistingPullRequests, getPullRequestNumberFromCheckoutRef, IPullRequestQuickPickItem, mergePullRequestSummaries, pullRequestMatchesQuery, resolvePullRequestSessionRepository } from '../../browser/pullRequestPicker.js';
|
||||
import { createPullRequestBootstrapPrompt, createPullRequestContextAttachment, createPullRequestQuickPickItems, createPullRequestSessionMetadata, getExistingPullRequests, getPullRequestNumberFromCheckoutRef, IPullRequestQuickPickItem, isPullRequestAvailable, mergePullRequestSummaries, pullRequestMatchesQuery, resolvePullRequestSessionRepository } from '../../browser/pullRequestPicker.js';
|
||||
import { IGitHubPullRequestSummary } from '../../common/types.js';
|
||||
import { createAndOpenPullRequestSession } from '../../browser/pullRequestSessionCreation.js';
|
||||
|
||||
@@ -86,6 +86,22 @@ suite('Create Session from Pull Request', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('only makes same-repository pull requests without existing sessions available', () => {
|
||||
const existingPullRequests = { numbers: new Set([1]), headRefs: new Set(['feature-2']) };
|
||||
|
||||
assert.deepStrictEqual([
|
||||
pullRequest(1),
|
||||
pullRequest(2),
|
||||
pullRequest(3, { isCrossRepository: true }),
|
||||
pullRequest(4),
|
||||
].map(item => isPullRequestAvailable(item, existingPullRequests)), [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
test('merges viewer-group results into the loaded catalog without dropping either set', () => {
|
||||
const merged = mergePullRequestSummaries([
|
||||
pullRequest(1),
|
||||
@@ -295,6 +311,7 @@ function pullRequest(number: number, overrides: Partial<IGitHubPullRequestSummar
|
||||
title: `Pull request ${number}`,
|
||||
author: { login: 'author', avatarUrl: '' },
|
||||
headRef: `feature-${number}`,
|
||||
isCrossRepository: false,
|
||||
isDraft: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
additions: 1,
|
||||
|
||||
@@ -407,13 +407,16 @@ export class AgentHostSessionConfigPicker extends Disposable {
|
||||
if (!this._isPickable(schema)) {
|
||||
continue;
|
||||
}
|
||||
// A hidden carrier property (see `worktreeBranchTrackProperty` in
|
||||
// Hidden carrier properties (see `worktreeBranchTrackProperty` in
|
||||
// `worktreeIsolation.ts`) consumed only by the host for worktree
|
||||
// isolation, never edited by the user. Its boolean type otherwise
|
||||
// passes `_isPickable` unlike its string/array carrier siblings
|
||||
// (`worktreeBranchPrefix`/`worktreeIncludeFiles`), which are
|
||||
// filtered out because they lack an `enum`.
|
||||
if (property === SessionConfigKey.WorktreeBranchTrack) {
|
||||
if (
|
||||
property === SessionConfigKey.WorktreeBranchTrack ||
|
||||
property === SessionConfigKey.WorktreeCreateNewBranch
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (property === SessionConfigKey.Isolation && !schema.enum?.includes('worktree')) {
|
||||
|
||||
@@ -3621,6 +3621,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
|
||||
if (configuration.worktreeBranchTrack !== undefined) {
|
||||
values[SessionConfigKey.WorktreeBranchTrack] = configuration.worktreeBranchTrack;
|
||||
}
|
||||
if (configuration.worktreeCreateNewBranch !== undefined) {
|
||||
values[SessionConfigKey.WorktreeCreateNewBranch] = configuration.worktreeCreateNewBranch;
|
||||
}
|
||||
if (configuration.branch) {
|
||||
values[SessionConfigKey.Branch] = normalizeSessionConfigValue(SessionConfigKey.Branch, configuration.branch, policyRestricted);
|
||||
}
|
||||
@@ -3631,6 +3634,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement
|
||||
await this._setTransientNewSessionConfigValue(sessionId, SessionConfigKey.WorktreeBranchTrack, enabled);
|
||||
}
|
||||
|
||||
async setWorktreeCreateNewBranch(sessionId: string, enabled: boolean): Promise<void> {
|
||||
await this._setTransientNewSessionConfigValue(sessionId, SessionConfigKey.WorktreeCreateNewBranch, enabled);
|
||||
}
|
||||
|
||||
async setBranch(sessionId: string, branch: string): Promise<void> {
|
||||
const policyRestricted = isAutoApprovePolicyRestricted(this._baseConfigurationService);
|
||||
const value = normalizeSessionConfigValue(SessionConfigKey.Branch, branch, policyRestricted);
|
||||
|
||||
+7
-3
@@ -392,7 +392,7 @@ suite('Agent Host Session Config Picker', () => {
|
||||
assert.strictEqual(isolationSlot(container), null);
|
||||
});
|
||||
|
||||
test('never renders a chip for the hidden worktreeBranchTrack carrier property', () => {
|
||||
test('never renders chips for hidden worktree branch carrier properties', () => {
|
||||
const services = setupServices(store);
|
||||
services.provider.config = {
|
||||
schema: {
|
||||
@@ -407,14 +407,18 @@ suite('Agent Host Session Config Picker', () => {
|
||||
title: 'Track Branch', description: '', type: 'boolean',
|
||||
default: false, readOnly: true, sessionMutable: false,
|
||||
},
|
||||
[SessionConfigKey.WorktreeCreateNewBranch]: {
|
||||
title: 'Create New Branch', description: '', type: 'boolean',
|
||||
default: true, readOnly: true, sessionMutable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
values: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.WorktreeBranchTrack]: false },
|
||||
values: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.WorktreeBranchTrack]: false, [SessionConfigKey.WorktreeCreateNewBranch]: true },
|
||||
} as ResolveSessionConfigResult;
|
||||
const picker = store.add(services.instantiationService.createInstance(AlwaysRenderConfigPicker, services.sessionObs));
|
||||
const container = document.createElement('div');
|
||||
picker.render(container);
|
||||
|
||||
assert.strictEqual(container.querySelectorAll('.sessions-chat-picker-slot').length, 1, 'only the isolation checkbox renders, not a worktreeBranchTrack chip');
|
||||
assert.strictEqual(container.querySelectorAll('.sessions-chat-picker-slot').length, 1, 'only the isolation checkbox renders');
|
||||
});
|
||||
});
|
||||
|
||||
+4
@@ -3276,6 +3276,7 @@ suite('LocalAgentHostSessionsProvider', () => {
|
||||
values: {
|
||||
[SessionConfigKey.Isolation]: 'worktree',
|
||||
[SessionConfigKey.WorktreeBranchTrack]: true,
|
||||
[SessionConfigKey.WorktreeCreateNewBranch]: false,
|
||||
[SessionConfigKey.Branch]: 'feature/pull-request',
|
||||
},
|
||||
};
|
||||
@@ -3283,6 +3284,7 @@ suite('LocalAgentHostSessionsProvider', () => {
|
||||
const setting = provider.setWorktreeConfiguration(session.sessionId, {
|
||||
isolationMode: 'worktree',
|
||||
worktreeBranchTrack: true,
|
||||
worktreeCreateNewBranch: false,
|
||||
branch: 'feature/pull-request',
|
||||
});
|
||||
await timeout(0);
|
||||
@@ -3299,12 +3301,14 @@ suite('LocalAgentHostSessionsProvider', () => {
|
||||
{
|
||||
[SessionConfigKey.Isolation]: 'worktree',
|
||||
[SessionConfigKey.WorktreeBranchTrack]: true,
|
||||
[SessionConfigKey.WorktreeCreateNewBranch]: false,
|
||||
[SessionConfigKey.Branch]: 'feature/pull-request',
|
||||
},
|
||||
],
|
||||
config: {
|
||||
[SessionConfigKey.Isolation]: 'worktree',
|
||||
[SessionConfigKey.WorktreeBranchTrack]: true,
|
||||
[SessionConfigKey.WorktreeCreateNewBranch]: false,
|
||||
[SessionConfigKey.Branch]: 'feature/pull-request',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -407,6 +407,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa
|
||||
let sessionTypeId: string | undefined;
|
||||
const requiresWorktreeConfiguration = options?.isolationMode === 'worktree'
|
||||
|| options?.worktreeBranchTrack !== undefined
|
||||
|| options?.worktreeCreateNewBranch !== undefined
|
||||
|| options?.branch !== undefined;
|
||||
const resolveSessionTypeId = (candidate: ISessionsProvider): string | undefined => {
|
||||
const sessionTypes = candidate.getSessionTypes(folderUri);
|
||||
@@ -834,11 +835,12 @@ export class SessionsManagementService extends Disposable implements ISessionsMa
|
||||
if (createOptions?.permissionLevel) {
|
||||
provider.setPermissionLevel?.(session.sessionId, createOptions.permissionLevel);
|
||||
}
|
||||
if (supportsWorktreeConfiguration && (createOptions?.isolationMode || createOptions?.worktreeBranchTrack !== undefined || createOptions?.branch)) {
|
||||
if (supportsWorktreeConfiguration && (createOptions?.isolationMode || createOptions?.worktreeBranchTrack !== undefined || createOptions?.worktreeCreateNewBranch !== undefined || createOptions?.branch)) {
|
||||
if (provider.setWorktreeConfiguration) {
|
||||
await raceCancellationError(provider.setWorktreeConfiguration(session.sessionId, {
|
||||
isolationMode: createOptions.isolationMode,
|
||||
worktreeBranchTrack: createOptions.worktreeBranchTrack,
|
||||
worktreeCreateNewBranch: createOptions.worktreeCreateNewBranch,
|
||||
branch: createOptions.branch,
|
||||
}), token);
|
||||
} else {
|
||||
@@ -848,6 +850,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa
|
||||
if (createOptions.worktreeBranchTrack !== undefined && provider.setWorktreeBranchTrack) {
|
||||
await raceCancellationError(provider.setWorktreeBranchTrack(session.sessionId, createOptions.worktreeBranchTrack), token);
|
||||
}
|
||||
if (createOptions.worktreeCreateNewBranch !== undefined && provider.setWorktreeCreateNewBranch) {
|
||||
await raceCancellationError(provider.setWorktreeCreateNewBranch(session.sessionId, createOptions.worktreeCreateNewBranch), token);
|
||||
}
|
||||
if (createOptions.branch && provider.setBranch) {
|
||||
await raceCancellationError(provider.setBranch(session.sessionId, createOptions.branch), token);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ISessionsPartService } from './sessionsPartService.js';
|
||||
import { ICustomViewService } from '../../customView/browser/customViewService.js';
|
||||
import { IsNewChatSessionContext } from '../../../common/contextkeys.js';
|
||||
import { setActiveSessionContextKeys } from '../common/sessionContextKeys.js';
|
||||
import { ISessionChangesStatsCache } from '../common/sessionChangesStatsCache.js';
|
||||
|
||||
const ACTIVE_SESSION_STATES_KEY = 'agentSessions.activeSessionStates';
|
||||
|
||||
@@ -354,6 +355,7 @@ export class SessionsService extends Disposable implements ISessionsService {
|
||||
@ICustomViewService private readonly customViewService: ICustomViewService,
|
||||
@IInstantiationService private readonly instantiationService: IInstantiationService,
|
||||
@IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService,
|
||||
@ISessionChangesStatsCache private readonly changesStatsCache: ISessionChangesStatsCache,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -416,7 +418,7 @@ export class SessionsService extends Disposable implements ISessionsService {
|
||||
// sent for the first time). Scoping to the active session avoids flipping
|
||||
// into "new chat" mode while viewing a different established session.
|
||||
this._isNewChatSessionContext.set(activeSession === undefined || activeSession.sessionId === newSession?.sessionId);
|
||||
setActiveSessionContextKeys(activeSession, this.contextKeyService, reader);
|
||||
setActiveSessionContextKeys(activeSession, this.contextKeyService, reader, this.changesStatsCache);
|
||||
}));
|
||||
|
||||
// Per-active-session view reactions (archived → new-session view,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Disposable } from '../../../../base/common/lifecycle.js';
|
||||
import { IReader, ISettableObservable, observableValue } from '../../../../base/common/observable.js';
|
||||
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
|
||||
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
|
||||
import { ISession } from './session.js';
|
||||
|
||||
/** The aggregate diff counts the changes pill reports for a session. */
|
||||
export interface ISessionChangesStats {
|
||||
readonly files: number;
|
||||
readonly insertions: number;
|
||||
readonly deletions: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's aggregate changes as the changes pill reports them, or `undefined`
|
||||
* while the session has not reported any changes data yet.
|
||||
*
|
||||
* The provider-supplied {@link ISession.changesSummary} is the authoritative
|
||||
* aggregate; without it the changes of the default changeset (or the session's
|
||||
* top-level changes when no changeset is default) are aggregated. A session with
|
||||
* neither a summary nor any changeset has not reported yet — an empty changeset
|
||||
* list, in contrast, is a reported "no changes".
|
||||
*/
|
||||
export function readSessionChangesStats(session: ISession, reader: IReader | undefined): ISessionChangesStats | undefined {
|
||||
const summary = session.changesSummary?.read(reader);
|
||||
if (summary) {
|
||||
return { files: summary.files, insertions: summary.additions, deletions: summary.deletions };
|
||||
}
|
||||
|
||||
const changesets = session.changesets.read(reader);
|
||||
const defaultChangeset = changesets?.find(changeset => changeset.isDefault.read(reader));
|
||||
const changes = defaultChangeset?.changes.read(reader) ?? session.changes.read(reader);
|
||||
if (changesets === undefined && changes.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let insertions = 0, deletions = 0;
|
||||
for (const change of changes) {
|
||||
insertions += change.insertions;
|
||||
deletions += change.deletions;
|
||||
}
|
||||
return { files: changes.length, insertions, deletions };
|
||||
}
|
||||
|
||||
function sessionChangesStatsEqual(a: ISessionChangesStats | undefined, b: ISessionChangesStats | undefined): boolean {
|
||||
if (!a || !b) {
|
||||
return a === b;
|
||||
}
|
||||
return a.files === b.files && a.insertions === b.insertions && a.deletions === b.deletions;
|
||||
}
|
||||
|
||||
export const ISessionChangesStatsCache = createDecorator<ISessionChangesStatsCache>('sessionChangesStatsCache');
|
||||
|
||||
/**
|
||||
* Remembers the changes pill last shown for a session so it can be rendered
|
||||
* optimistically the next time that session is opened, instead of only appearing
|
||||
* once the provider has reported its (often late) changes data.
|
||||
*
|
||||
* The cache is bounded and persisted in global storage, so it survives restarts
|
||||
* and is shared by every window.
|
||||
*/
|
||||
export interface ISessionChangesStatsCache {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** The stats last recorded for `sessionId`, if still cached. */
|
||||
get(sessionId: string, reader: IReader | undefined): ISessionChangesStats | undefined;
|
||||
|
||||
/**
|
||||
* Records the stats currently shown for `sessionId`, making it the most
|
||||
* recent entry. Stats without files drop the entry, so a session whose
|
||||
* changes went away does not keep an optimistic pill.
|
||||
*/
|
||||
set(sessionId: string, stats: ISessionChangesStats): void;
|
||||
}
|
||||
|
||||
/** How many sessions are remembered; the oldest entry is evicted beyond this. */
|
||||
export const MAX_CACHED_SESSION_CHANGES_STATS = 30;
|
||||
|
||||
const STORAGE_KEY = 'sessions.changesStatsCache';
|
||||
|
||||
interface IStoredEntry {
|
||||
readonly sessionId: string;
|
||||
readonly files: number;
|
||||
readonly insertions: number;
|
||||
readonly deletions: number;
|
||||
}
|
||||
|
||||
/** Exported for direct instantiation in tests; consumers should depend on {@link ISessionChangesStatsCache}. */
|
||||
export class SessionChangesStatsCache extends Disposable implements ISessionChangesStatsCache {
|
||||
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
/** Insertion ordered, oldest entry first. */
|
||||
private readonly _entries: ISettableObservable<ReadonlyMap<string, ISessionChangesStats>>;
|
||||
|
||||
constructor(
|
||||
@IStorageService private readonly _storageService: IStorageService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._entries = observableValue<ReadonlyMap<string, ISessionChangesStats>>(this, this._load());
|
||||
|
||||
// Every window shares the cache, so pick up what another window recorded.
|
||||
this._register(this._storageService.onDidChangeValue(StorageScope.APPLICATION, STORAGE_KEY, this._store)(e => {
|
||||
if (e.external) {
|
||||
this._entries.set(this._load(), undefined);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
get(sessionId: string, reader: IReader | undefined): ISessionChangesStats | undefined {
|
||||
return this._entries.read(reader).get(sessionId);
|
||||
}
|
||||
|
||||
set(sessionId: string, stats: ISessionChangesStats): void {
|
||||
const current = this._entries.get();
|
||||
const existing = current.get(sessionId);
|
||||
if (stats.files === 0 && existing === undefined) {
|
||||
return;
|
||||
}
|
||||
const isNewest = sessionId === Array.from(current.keys()).at(-1);
|
||||
if (isNewest && sessionChangesStatsEqual(existing, stats)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-inserting moves the session to the end, so eviction always drops the
|
||||
// session whose pill was recorded longest ago.
|
||||
const updated = new Map(current);
|
||||
updated.delete(sessionId);
|
||||
if (stats.files > 0) {
|
||||
updated.set(sessionId, stats);
|
||||
}
|
||||
|
||||
while (updated.size > MAX_CACHED_SESSION_CHANGES_STATS) {
|
||||
const oldest = updated.keys().next().value;
|
||||
if (oldest === undefined) {
|
||||
break;
|
||||
}
|
||||
updated.delete(oldest);
|
||||
}
|
||||
|
||||
this._entries.set(updated, undefined);
|
||||
this._save(updated);
|
||||
}
|
||||
|
||||
private _load(): ReadonlyMap<string, ISessionChangesStats> {
|
||||
const entries = new Map<string, ISessionChangesStats>();
|
||||
const raw = this._storageService.get(STORAGE_KEY, StorageScope.APPLICATION);
|
||||
if (!raw) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored: readonly IStoredEntry[] = JSON.parse(raw);
|
||||
if (!Array.isArray(stored)) {
|
||||
return entries;
|
||||
}
|
||||
for (const entry of stored.slice(-MAX_CACHED_SESSION_CHANGES_STATS)) {
|
||||
if (typeof entry?.sessionId === 'string' && typeof entry.files === 'number' && typeof entry.insertions === 'number' && typeof entry.deletions === 'number') {
|
||||
entries.set(entry.sessionId, { files: entry.files, insertions: entry.insertions, deletions: entry.deletions });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Corrupt state starts over rather than breaking the pill.
|
||||
return new Map();
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private _save(entries: ReadonlyMap<string, ISessionChangesStats>): void {
|
||||
if (entries.size === 0) {
|
||||
this._storageService.remove(STORAGE_KEY, StorageScope.APPLICATION);
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = [...entries].map(([sessionId, stats]) => ({ sessionId, ...stats } satisfies IStoredEntry));
|
||||
this._storageService.store(STORAGE_KEY, JSON.stringify(stored), StorageScope.APPLICATION, StorageTarget.MACHINE);
|
||||
}
|
||||
}
|
||||
|
||||
registerSingleton(ISessionChangesStatsCache, SessionChangesStatsCache, InstantiationType.Delayed);
|
||||
@@ -8,6 +8,7 @@ import { isEqual } from '../../../../base/common/resources.js';
|
||||
import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
|
||||
import {
|
||||
SessionHasChangesContext,
|
||||
SessionHasCachedChangesContext,
|
||||
SessionHasPullRequestContext,
|
||||
SessionHasIssuesContext,
|
||||
SessionHasWorkspaceContext,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
SessionHasGitRepositoryContext,
|
||||
} from '../../../common/contextkeys.js';
|
||||
import { ChatOriginKind, getChatCapabilities, isActiveSessionStatus, ISession, SessionStatus } from './session.js';
|
||||
import { ISessionChangesStatsCache, readSessionChangesStats } from './sessionChangesStatsCache.js';
|
||||
import { IActiveSession } from './sessionsManagement.js';
|
||||
|
||||
/**
|
||||
@@ -55,6 +57,7 @@ interface ISessionContextKeys {
|
||||
readonly workspaceIsVirtual: IContextKey<boolean>;
|
||||
readonly hasGitRepository: IContextKey<boolean>;
|
||||
readonly hasChanges: IContextKey<boolean>;
|
||||
readonly hasCachedChanges: IContextKey<boolean>;
|
||||
readonly hasPullRequest: IContextKey<boolean>;
|
||||
readonly hasIssues: IContextKey<boolean>;
|
||||
readonly hasWorkspace: IContextKey<boolean>;
|
||||
@@ -97,6 +100,7 @@ function getBoundKeys(contextKeyService: IContextKeyService): ISessionContextKey
|
||||
workspaceIsVirtual: SessionWorkspaceIsVirtualContext.bindTo(contextKeyService),
|
||||
hasGitRepository: SessionHasGitRepositoryContext.bindTo(contextKeyService),
|
||||
hasChanges: SessionHasChangesContext.bindTo(contextKeyService),
|
||||
hasCachedChanges: SessionHasCachedChangesContext.bindTo(contextKeyService),
|
||||
hasPullRequest: SessionHasPullRequestContext.bindTo(contextKeyService),
|
||||
hasIssues: SessionHasIssuesContext.bindTo(contextKeyService),
|
||||
hasWorkspace: SessionHasWorkspaceContext.bindTo(contextKeyService),
|
||||
@@ -128,8 +132,11 @@ function getBoundKeys(contextKeyService: IContextKeyService): ISessionContextKey
|
||||
*
|
||||
* Passing `undefined` for `session` resets the keys to their defaults (e.g. for
|
||||
* the empty new-session slot).
|
||||
*
|
||||
* Pass the `changesStatsCache` on surfaces that render the changes pill so it can
|
||||
* be shown optimistically while the session's own changes are still loading.
|
||||
*/
|
||||
export function setSessionContextKeys(session: ISession | undefined, contextKeyService: IContextKeyService, reader: IReader | undefined): void {
|
||||
export function setSessionContextKeys(session: ISession | undefined, contextKeyService: IContextKeyService, reader: IReader | undefined, changesStatsCache?: ISessionChangesStatsCache): void {
|
||||
const keys = getBoundKeys(contextKeyService);
|
||||
keys.sessionId.set(session?.sessionId ?? '');
|
||||
keys.providerId.set(session?.providerId ?? '');
|
||||
@@ -158,6 +165,13 @@ export function setSessionContextKeys(session: ISession | undefined, contextKeyS
|
||||
}
|
||||
keys.hasChanges.set(!worktreePending && (insertions > 0 || deletions > 0));
|
||||
|
||||
// A session reports its changes late, so until it does the pill it last showed
|
||||
// is rendered from the cache. The remembered pill is dropped as soon as the
|
||||
// session reports its own changes, even when it reports none.
|
||||
const changesReported = session ? readSessionChangesStats(session, reader) !== undefined : true;
|
||||
const cachedFiles = !changesReported && session ? changesStatsCache?.get(session.sessionId, reader)?.files ?? 0 : 0;
|
||||
keys.hasCachedChanges.set(!worktreePending && cachedFiles > 0);
|
||||
|
||||
const pullRequest = session?.workspace.read(reader)?.folders[0]?.gitRepository?.gitHubInfo.read(reader)?.pullRequest;
|
||||
keys.hasPullRequest.set(!!pullRequest);
|
||||
|
||||
@@ -181,8 +195,8 @@ export function setSessionContextKeys(session: ISession | undefined, contextKeyS
|
||||
*
|
||||
* See {@link setSessionContextKeys} for the `reader` and `undefined` semantics.
|
||||
*/
|
||||
export function setActiveSessionContextKeys(session: IActiveSession | undefined, contextKeyService: IContextKeyService, reader: IReader | undefined): void {
|
||||
setSessionContextKeys(session, contextKeyService, reader);
|
||||
export function setActiveSessionContextKeys(session: IActiveSession | undefined, contextKeyService: IContextKeyService, reader: IReader | undefined, changesStatsCache?: ISessionChangesStatsCache): void {
|
||||
setSessionContextKeys(session, contextKeyService, reader, changesStatsCache);
|
||||
const keys = getBoundKeys(contextKeyService);
|
||||
keys.isCreated.set(session?.isCreated.read(reader) ?? false);
|
||||
keys.sticky.set(session?.sticky.read(reader) ?? false);
|
||||
|
||||
@@ -110,6 +110,10 @@ export interface ICreateNewSessionOptions {
|
||||
* programmatic session creation and is not surfaced in the new-session UI.
|
||||
*/
|
||||
readonly worktreeBranchTrack?: boolean;
|
||||
/**
|
||||
* Whether to create a generated worktree branch from {@link branch}.
|
||||
*/
|
||||
readonly worktreeCreateNewBranch?: boolean;
|
||||
/**
|
||||
* Invoked after the provider creates the provisional session, before its
|
||||
* configuration and first request are applied.
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface ISessionsProviderCreateSessionOptions {
|
||||
export interface ISessionWorktreeConfiguration {
|
||||
readonly isolationMode?: string;
|
||||
readonly worktreeBranchTrack?: boolean;
|
||||
readonly worktreeCreateNewBranch?: boolean;
|
||||
readonly branch?: string;
|
||||
}
|
||||
|
||||
@@ -366,6 +367,9 @@ export interface ISessionsProvider {
|
||||
*/
|
||||
setWorktreeBranchTrack?(sessionId: string, enabled: boolean): Promise<void>;
|
||||
|
||||
/** Set whether the worktree creates a new branch for a session. */
|
||||
setWorktreeCreateNewBranch?(sessionId: string, enabled: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Set the git branch for a session.
|
||||
* @param sessionId The ID of the session.
|
||||
|
||||
@@ -1628,6 +1628,7 @@ suite('SessionsManagementService', () => {
|
||||
override async setIsolationMode(_sessionId: string, _mode: string): Promise<void> { calls.push(`setIsolationMode:${_mode}`); }
|
||||
override async setBranch(_sessionId: string, _branch: string): Promise<void> { calls.push(`setBranch:${_branch}`); }
|
||||
override async setWorktreeBranchTrack(_sessionId: string, _enabled: boolean): Promise<void> { calls.push(`setWorktreeBranchTrack:${_enabled}`); }
|
||||
override async setWorktreeCreateNewBranch(_sessionId: string, _enabled: boolean): Promise<void> { calls.push(`setWorktreeCreateNewBranch:${_enabled}`); }
|
||||
override async sendRequest(_sessionId: string, _chatResource: URI, options: ISendRequestOptions): Promise<ISession> {
|
||||
sentOptions = options;
|
||||
return session;
|
||||
@@ -1641,6 +1642,7 @@ suite('SessionsManagementService', () => {
|
||||
permissionLevel: 'allowedTools',
|
||||
isolationMode: 'worktree',
|
||||
worktreeBranchTrack: false,
|
||||
worktreeCreateNewBranch: true,
|
||||
branch: 'main',
|
||||
};
|
||||
const result = await service.createAndSendNewChatRequest(URI.parse('test:///folder'), { query: 'hi', title: 'Pull Request', hideFromTranscript: true }, createOptions);
|
||||
@@ -1657,6 +1659,7 @@ suite('SessionsManagementService', () => {
|
||||
'setPermissionLevel:allowedTools',
|
||||
'setIsolationMode:worktree',
|
||||
'setWorktreeBranchTrack:false',
|
||||
'setWorktreeCreateNewBranch:true',
|
||||
'setBranch:main',
|
||||
],
|
||||
sentOptions: { query: 'hi', title: 'Pull Request', hideFromTranscript: true },
|
||||
@@ -1681,6 +1684,7 @@ suite('SessionsManagementService', () => {
|
||||
await service.createAndSendNewChatRequest(URI.parse('test:///folder'), { query: 'hi' }, {
|
||||
isolationMode: 'worktree',
|
||||
worktreeBranchTrack: true,
|
||||
worktreeCreateNewBranch: false,
|
||||
branch: 'feature',
|
||||
onSessionCreated: created => {
|
||||
calls.push(`created:${created.sessionId}:${service.getSession(created.resource)?.sessionId}`);
|
||||
@@ -1694,7 +1698,7 @@ suite('SessionsManagementService', () => {
|
||||
}, {
|
||||
calls: [
|
||||
'created:s1:s1',
|
||||
'setWorktreeConfiguration:{"isolationMode":"worktree","worktreeBranchTrack":true,"branch":"feature"}',
|
||||
'setWorktreeConfiguration:{"isolationMode":"worktree","worktreeBranchTrack":true,"worktreeCreateNewBranch":false,"branch":"feature"}',
|
||||
],
|
||||
activeSession: 's1',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { constObservable } from '../../../../../base/common/observable.js';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { upcastPartial } from '../../../../../base/test/common/mock.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js';
|
||||
import { ISession, ISessionChangeset } from '../../common/session.js';
|
||||
import { ISessionChangesStats, MAX_CACHED_SESSION_CHANGES_STATS, readSessionChangesStats, SessionChangesStatsCache } from '../../common/sessionChangesStatsCache.js';
|
||||
|
||||
const stats = (files: number): ISessionChangesStats => ({ files, insertions: files * 10, deletions: files });
|
||||
|
||||
suite('SessionChangesStatsCache', () => {
|
||||
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
function createCache(storageService = disposables.add(new TestStorageService())): SessionChangesStatsCache {
|
||||
return disposables.add(new SessionChangesStatsCache(storageService));
|
||||
}
|
||||
|
||||
test('remembers the stats last recorded for a session', () => {
|
||||
const cache = createCache();
|
||||
cache.set('a', stats(2));
|
||||
cache.set('a', stats(3));
|
||||
|
||||
assert.deepStrictEqual({ a: cache.get('a', undefined), b: cache.get('b', undefined) }, {
|
||||
a: { files: 3, insertions: 30, deletions: 3 },
|
||||
b: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('drops the entry of a session that no longer has changes', () => {
|
||||
const cache = createCache();
|
||||
cache.set('a', stats(2));
|
||||
cache.set('a', { files: 0, insertions: 0, deletions: 0 });
|
||||
|
||||
assert.strictEqual(cache.get('a', undefined), undefined);
|
||||
});
|
||||
|
||||
test('evicts the oldest entry once the cache is full', () => {
|
||||
const cache = createCache();
|
||||
for (let i = 0; i < MAX_CACHED_SESSION_CHANGES_STATS; i++) {
|
||||
cache.set(`session-${i}`, stats(1));
|
||||
}
|
||||
// Re-recording the oldest session makes it the most recent one again, so
|
||||
// the next session evicts the one after it instead.
|
||||
cache.set('session-0', stats(2));
|
||||
cache.set('overflow', stats(1));
|
||||
|
||||
assert.deepStrictEqual({
|
||||
evicted: cache.get('session-1', undefined),
|
||||
refreshed: cache.get('session-0', undefined),
|
||||
added: cache.get('overflow', undefined),
|
||||
}, {
|
||||
evicted: undefined,
|
||||
refreshed: { files: 2, insertions: 20, deletions: 2 },
|
||||
added: { files: 1, insertions: 10, deletions: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test('restores the cache from global storage', () => {
|
||||
const storageService = disposables.add(new TestStorageService());
|
||||
createCache(storageService).set('a', stats(4));
|
||||
|
||||
assert.deepStrictEqual(createCache(storageService).get('a', undefined), { files: 4, insertions: 40, deletions: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
suite('readSessionChangesStats', () => {
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
function stubSession(overrides: Partial<ISession>): ISession {
|
||||
return upcastPartial<ISession>({
|
||||
sessionId: 'a',
|
||||
changesets: constObservable(undefined),
|
||||
changes: constObservable([]),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
const change = { modifiedUri: URI.parse('test:///file.ts'), insertions: 3, deletions: 1 };
|
||||
|
||||
test('reports stats only once the session reported its changes', () => {
|
||||
const notReported = stubSession({});
|
||||
const reportedNone = stubSession({ changesets: constObservable([]) });
|
||||
const summarized = stubSession({ changesSummary: constObservable({ files: 5, additions: 20, deletions: 7 }) });
|
||||
const changeset = upcastPartial<ISessionChangeset>({ isDefault: constObservable(true), changes: constObservable([change]) });
|
||||
const fromChangeset = stubSession({ changesets: constObservable([changeset]) });
|
||||
const fromSessionChanges = stubSession({ changes: constObservable([change]) });
|
||||
|
||||
assert.deepStrictEqual({
|
||||
notReported: readSessionChangesStats(notReported, undefined),
|
||||
reportedNone: readSessionChangesStats(reportedNone, undefined),
|
||||
summarized: readSessionChangesStats(summarized, undefined),
|
||||
fromChangeset: readSessionChangesStats(fromChangeset, undefined),
|
||||
fromSessionChanges: readSessionChangesStats(fromSessionChanges, undefined),
|
||||
}, {
|
||||
notReported: undefined,
|
||||
reportedNone: { files: 0, insertions: 0, deletions: 0 },
|
||||
summarized: { files: 5, insertions: 20, deletions: 7 },
|
||||
fromChangeset: { files: 1, insertions: 3, deletions: 1 },
|
||||
fromSessionChanges: { files: 1, insertions: 3, deletions: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,13 @@ import { URI } from '../../../../../base/common/uri.js';
|
||||
import { upcastPartial } from '../../../../../base/test/common/mock.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js';
|
||||
import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js';
|
||||
import { IChatSessionFileChange } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js';
|
||||
import { SessionActiveChatHasSubagentsContext, SessionHasChangesContext, SessionHasGitRepositoryContext, SessionHasMultipleCommittedChatsContext, SessionIsActiveContext, SessionSupportsSideChatContext } from '../../../../common/contextkeys.js';
|
||||
import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } from '../../common/session.js';
|
||||
import { SessionActiveChatHasSubagentsContext, SessionHasCachedChangesContext, SessionHasChangesContext, SessionHasGitRepositoryContext, SessionHasMultipleCommittedChatsContext, SessionIsActiveContext, SessionSupportsSideChatContext } from '../../../../common/contextkeys.js';
|
||||
import { ChatInteractivity, ChatOriginKind, IChat, ISession, ISessionChangeset, SessionStatus } from '../../common/session.js';
|
||||
import { IActiveSession } from '../../common/sessionsManagement.js';
|
||||
import { setActiveSessionContextKeys, setSessionContextKeys } from '../../common/sessionContextKeys.js';
|
||||
import { SessionChangesStatsCache } from '../../common/sessionChangesStatsCache.js';
|
||||
|
||||
function createSession(hasGitRepository: ISettableObservable<boolean>): ISession {
|
||||
return upcastPartial<ISession>({
|
||||
@@ -154,6 +156,24 @@ suite('setSessionContextKeys - changes', () => {
|
||||
afterWorktreeCreated: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('reports the cached changes of a session until it reports its own', () => {
|
||||
const contextKeyService = disposables.add(new MockContextKeyService());
|
||||
const cache = disposables.add(new SessionChangesStatsCache(disposables.add(new TestStorageService())));
|
||||
cache.set('a', { files: 2, insertions: 5, deletions: 1 });
|
||||
const changesets = observableValue<readonly ISessionChangeset[] | undefined>('changesets', undefined);
|
||||
const session = stubSession({ sessionId: 'a', changesets, changes: constObservable([]) });
|
||||
|
||||
disposables.add(autorun(reader => setSessionContextKeys(session, contextKeyService, reader, cache)));
|
||||
const beforeReported = SessionHasCachedChangesContext.getValue(contextKeyService);
|
||||
|
||||
changesets.set([], undefined);
|
||||
|
||||
assert.deepStrictEqual({ beforeReported, afterReportedNoChanges: SessionHasCachedChangesContext.getValue(contextKeyService) }, {
|
||||
beforeReported: true,
|
||||
afterReportedNoChanges: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('setSessionContextKeys - side chat', () => {
|
||||
|
||||
@@ -314,7 +314,10 @@ export class MainThreadEditorTabs implements MainThreadEditorTabsShape {
|
||||
if (!tabInfo) {
|
||||
return;
|
||||
}
|
||||
tabInfo.tab = this._buildTabObject(group, editorInput, editorIndex);
|
||||
// Refresh the DTO in place. The group's `tabs` array holds this very object,
|
||||
// so swapping in a new one would leave that copy behind and let the two
|
||||
// caches drift apart, e.g. a later update could re-send a stale `isActive`.
|
||||
Object.assign(tabInfo.tab, this._buildTabObject(group, editorInput, editorIndex));
|
||||
this._proxy.$acceptTabOperation({
|
||||
groupId,
|
||||
index: editorIndex,
|
||||
@@ -380,8 +383,15 @@ export class MainThreadEditorTabs implements MainThreadEditorTabsShape {
|
||||
return;
|
||||
}
|
||||
const activeTab = tabs[editorIndex];
|
||||
// No need to loop over as the exthost uses the most recently marked active tab
|
||||
activeTab.isActive = true;
|
||||
// Clear the flag on the other tabs of the group. Otherwise a later `TAB_UPDATE`
|
||||
// re-sending one of those still-cached DTOs (label, dirty, pin or preview change)
|
||||
// would repoint the exthost at a tab that is no longer active.
|
||||
for (const tab of tabs) {
|
||||
if (tab !== activeTab) {
|
||||
tab.isActive = false;
|
||||
}
|
||||
}
|
||||
// Send DTO update to the exthost
|
||||
this._proxy.$acceptTabOperation({
|
||||
groupId,
|
||||
|
||||
@@ -5,16 +5,26 @@
|
||||
|
||||
import assert from 'assert';
|
||||
import { Emitter, Event } from '../../../../base/common/event.js';
|
||||
import { observableValue, ValueWithChangeEventFromObservable } from '../../../../base/common/observable.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { mock } from '../../../../base/test/common/mock.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||||
import { ITextModelService } from '../../../../editor/common/services/resolverService.js';
|
||||
import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js';
|
||||
import { TestConfigurationService } from '../../../../platform/configuration/test/common/testConfigurationService.js';
|
||||
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { NullLogService } from '../../../../platform/log/common/log.js';
|
||||
import { GroupModelChangeKind } from '../../../common/editor.js';
|
||||
import { EditorInput } from '../../../common/editor/editorInput.js';
|
||||
import { MultiDiffEditorInput } from '../../../contrib/multiDiffEditor/browser/multiDiffEditorInput.js';
|
||||
import { IMultiDiffSourceResolverService, MultiDiffEditorItem } from '../../../contrib/multiDiffEditor/browser/multiDiffSourceResolverService.js';
|
||||
import { IEditorGroup, IEditorGroupsService, IModalEditorPart } from '../../../services/editor/common/editorGroupsService.js';
|
||||
import { IEditorsChangeEvent, IEditorService } from '../../../services/editor/common/editorService.js';
|
||||
import { ITextFileEditorModelManager, ITextFileService } from '../../../services/textfile/common/textfiles.js';
|
||||
import { TestEditorInput } from '../../../test/browser/workbenchTestServices.js';
|
||||
import { MainThreadEditorTabs } from '../../browser/mainThreadEditorTabs.js';
|
||||
import { MainThreadEditorTabsShape } from '../../common/extHost.protocol.js';
|
||||
import { ExtHostEditorTabs } from '../../common/extHostEditorTabs.js';
|
||||
import { SingleProxyRPCProtocol } from '../common/testRPCProtocol.js';
|
||||
|
||||
suite('MainThreadEditorTabs', () => {
|
||||
@@ -83,4 +93,213 @@ suite('MainThreadEditorTabs', () => {
|
||||
rebuildsAfterOpen: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('updating a background tab does not make it the active tab', async () => {
|
||||
class NamedEditorInput extends TestEditorInput {
|
||||
private _dirty = false;
|
||||
constructor(resource: URI, typeId: string, private _name: string) {
|
||||
super(resource, typeId);
|
||||
}
|
||||
override getName(): string { return this._name; }
|
||||
setName(name: string): void { this._name = name; }
|
||||
override isDirty(): boolean { return this._dirty; }
|
||||
setDirty(dirty: boolean): void { this._dirty = dirty; }
|
||||
}
|
||||
|
||||
const inputA = disposables.add(new NamedEditorInput(URI.parse('test:a'), 'testEditor', 'Panel A'));
|
||||
const inputB = disposables.add(new NamedEditorInput(URI.parse('test:b'), 'testEditor', 'Panel B'));
|
||||
let activeEditor: EditorInput = inputA;
|
||||
let sticky = false;
|
||||
let pinned = true;
|
||||
|
||||
const group = new class extends mock<IEditorGroup>() {
|
||||
override readonly id = 1;
|
||||
override get editors() { return [inputA, inputB]; }
|
||||
override isSticky() { return sticky; }
|
||||
override isPinned() { return pinned; }
|
||||
override isActive(editor: EditorInput) { return editor === activeEditor; }
|
||||
}();
|
||||
const editorGroupsService = new class extends mock<IEditorGroupsService>() {
|
||||
override readonly onDidAddGroup = Event.None;
|
||||
override readonly onDidRemoveGroup = Event.None;
|
||||
override readonly whenReady = Promise.resolve();
|
||||
override readonly activeModalEditorPart = undefined;
|
||||
override get groups(): readonly IEditorGroup[] { return [group]; }
|
||||
override getGroups(): readonly IEditorGroup[] { return [group]; }
|
||||
override get activeGroup(): IEditorGroup { return group; }
|
||||
override getGroup(): IEditorGroup | undefined { return group; }
|
||||
}();
|
||||
const editorChanges = disposables.add(new Emitter<IEditorsChangeEvent>());
|
||||
const editorService = new class extends mock<IEditorService>() {
|
||||
override readonly onDidEditorsChange = editorChanges.event;
|
||||
}();
|
||||
|
||||
// Drive a real ext host so the assertions are made against the actual API surface
|
||||
const extHostEditorTabs = new ExtHostEditorTabs(
|
||||
SingleProxyRPCProtocol(new class extends mock<MainThreadEditorTabsShape>() { })
|
||||
);
|
||||
disposables.add(new MainThreadEditorTabs(
|
||||
SingleProxyRPCProtocol(extHostEditorTabs),
|
||||
editorGroupsService,
|
||||
new TestConfigurationService(),
|
||||
new NullLogService(),
|
||||
editorService,
|
||||
));
|
||||
await Promise.resolve();
|
||||
|
||||
const activeTabLabel = () => extHostEditorTabs.tabGroups.activeTabGroup.activeTab?.label;
|
||||
const initial = activeTabLabel();
|
||||
|
||||
// Tab B becomes the active tab
|
||||
activeEditor = inputB;
|
||||
editorChanges.fire({
|
||||
groupId: group.id,
|
||||
event: { kind: GroupModelChangeKind.EDITOR_ACTIVE, editor: inputB, editorIndex: 1 }
|
||||
});
|
||||
const afterActivatingB = activeTabLabel();
|
||||
|
||||
// Any update to the background tab A must not steal the active tab
|
||||
inputA.setName('Panel A (2)');
|
||||
editorChanges.fire({
|
||||
groupId: group.id,
|
||||
event: { kind: GroupModelChangeKind.EDITOR_LABEL, editor: inputA, editorIndex: 0 }
|
||||
});
|
||||
const afterLabelChange = activeTabLabel();
|
||||
|
||||
inputA.setDirty(true);
|
||||
editorChanges.fire({
|
||||
groupId: group.id,
|
||||
event: { kind: GroupModelChangeKind.EDITOR_DIRTY, editor: inputA, editorIndex: 0 }
|
||||
});
|
||||
const afterDirtyChange = activeTabLabel();
|
||||
|
||||
sticky = true;
|
||||
editorChanges.fire({
|
||||
groupId: group.id,
|
||||
event: { kind: GroupModelChangeKind.EDITOR_STICKY, editor: inputA, editorIndex: 0 }
|
||||
});
|
||||
const afterStickyChange = activeTabLabel();
|
||||
|
||||
pinned = false;
|
||||
editorChanges.fire({
|
||||
groupId: group.id,
|
||||
event: { kind: GroupModelChangeKind.EDITOR_PIN, editor: inputA, editorIndex: 0 }
|
||||
});
|
||||
const afterPreviewChange = activeTabLabel();
|
||||
|
||||
assert.deepStrictEqual({
|
||||
initial,
|
||||
afterActivatingB,
|
||||
afterLabelChange,
|
||||
afterDirtyChange,
|
||||
afterStickyChange,
|
||||
afterPreviewChange,
|
||||
}, {
|
||||
initial: 'Panel A',
|
||||
afterActivatingB: 'Panel B',
|
||||
afterLabelChange: 'Panel B',
|
||||
afterDirtyChange: 'Panel B',
|
||||
afterStickyChange: 'Panel B',
|
||||
afterPreviewChange: 'Panel B',
|
||||
});
|
||||
});
|
||||
|
||||
test('multi diff tab whose resources changed does not become the active tab', async () => {
|
||||
const resources = observableValue<readonly MultiDiffEditorItem[]>('resources', []);
|
||||
const sourceResolverService = new class extends mock<IMultiDiffSourceResolverService>() {
|
||||
override resolve() {
|
||||
return Promise.resolve({ resources: new ValueWithChangeEventFromObservable(resources) });
|
||||
}
|
||||
}();
|
||||
const textFileService = new class extends mock<ITextFileService>() {
|
||||
override readonly files = new class extends mock<ITextFileEditorModelManager>() {
|
||||
override readonly onDidChangeDirty = Event.None;
|
||||
}();
|
||||
}();
|
||||
const multiDiffInput = disposables.add(new MultiDiffEditorInput(
|
||||
URI.parse('multi-diff-editor:test'),
|
||||
'Multi Diff',
|
||||
undefined,
|
||||
false,
|
||||
new class extends mock<ITextModelService>() { }(),
|
||||
new class extends mock<ITextResourceConfigurationService>() { }(),
|
||||
new class extends mock<IInstantiationService>() { }(),
|
||||
sourceResolverService,
|
||||
textFileService,
|
||||
));
|
||||
await multiDiffInput.getViewModel();
|
||||
|
||||
const other = disposables.add(new TestEditorInput(URI.parse('test:other'), 'testEditor'));
|
||||
const editors: EditorInput[] = [other];
|
||||
let activeEditor: EditorInput = other;
|
||||
|
||||
const group = new class extends mock<IEditorGroup>() {
|
||||
override readonly id = 1;
|
||||
override get editors() { return editors; }
|
||||
override isSticky() { return false; }
|
||||
override isPinned() { return true; }
|
||||
override isActive(editor: EditorInput) { return editor === activeEditor; }
|
||||
}();
|
||||
const editorGroupsService = new class extends mock<IEditorGroupsService>() {
|
||||
override readonly onDidAddGroup = Event.None;
|
||||
override readonly onDidRemoveGroup = Event.None;
|
||||
override readonly whenReady = Promise.resolve();
|
||||
override readonly activeModalEditorPart = undefined;
|
||||
override get groups(): readonly IEditorGroup[] { return [group]; }
|
||||
override getGroups(): readonly IEditorGroup[] { return [group]; }
|
||||
override get activeGroup(): IEditorGroup { return group; }
|
||||
override getGroup(): IEditorGroup | undefined { return group; }
|
||||
}();
|
||||
const editorChanges = disposables.add(new Emitter<IEditorsChangeEvent>());
|
||||
const editorService = new class extends mock<IEditorService>() {
|
||||
override readonly onDidEditorsChange = editorChanges.event;
|
||||
}();
|
||||
|
||||
const extHostEditorTabs = new ExtHostEditorTabs(
|
||||
SingleProxyRPCProtocol(new class extends mock<MainThreadEditorTabsShape>() { })
|
||||
);
|
||||
disposables.add(new MainThreadEditorTabs(
|
||||
SingleProxyRPCProtocol(extHostEditorTabs),
|
||||
editorGroupsService,
|
||||
new TestConfigurationService(),
|
||||
new NullLogService(),
|
||||
editorService,
|
||||
));
|
||||
await Promise.resolve();
|
||||
|
||||
// Open the multi diff editor so that its resources listener gets registered
|
||||
editors.push(multiDiffInput);
|
||||
editorChanges.fire({
|
||||
groupId: group.id,
|
||||
event: { kind: GroupModelChangeKind.EDITOR_OPEN, editor: multiDiffInput, editorIndex: 1 }
|
||||
});
|
||||
|
||||
// It becomes the active tab, then its resources change while it is active
|
||||
activeEditor = multiDiffInput;
|
||||
editorChanges.fire({
|
||||
groupId: group.id,
|
||||
event: { kind: GroupModelChangeKind.EDITOR_ACTIVE, editor: multiDiffInput, editorIndex: 1 }
|
||||
});
|
||||
resources.set([], undefined);
|
||||
|
||||
// The other tab is activated, leaving the multi diff tab in the background
|
||||
activeEditor = other;
|
||||
editorChanges.fire({
|
||||
groupId: group.id,
|
||||
event: { kind: GroupModelChangeKind.EDITOR_ACTIVE, editor: other, editorIndex: 0 }
|
||||
});
|
||||
const afterActivatingOther = extHostEditorTabs.tabGroups.activeTabGroup.activeTab?.label;
|
||||
|
||||
// Updating the background multi diff tab must not hand it the active tab
|
||||
editorChanges.fire({
|
||||
groupId: group.id,
|
||||
event: { kind: GroupModelChangeKind.EDITOR_LABEL, editor: multiDiffInput, editorIndex: 1 }
|
||||
});
|
||||
const afterLabelChange = extHostEditorTabs.tabGroups.activeTabGroup.activeTab?.label;
|
||||
|
||||
assert.deepStrictEqual({ afterActivatingOther, afterLabelChange }, {
|
||||
afterActivatingOther: other.getName(),
|
||||
afterLabelChange: other.getName(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@ export class ChatDropdownPillActionViewItem extends ChatPillActionViewItem {
|
||||
}
|
||||
|
||||
protected override getAdditionalLabelContent(): Array<HTMLElement | string> {
|
||||
return this.isSummarized ? [$(`span.chat-pill-chevron${ThemeIcon.asCSSSelector(Codicon.chevronDown)}`, { 'aria-hidden': 'true' })] : [];
|
||||
return this.isSummarized ? [$(`span.chat-pill-chevron${ThemeIcon.asCSSSelector(Codicon.chevronDownCompact)}`, { 'aria-hidden': 'true' })] : [];
|
||||
}
|
||||
|
||||
protected override getTooltip(): string {
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.monaco-workbench .chat-pill-icon.codicon[class*='codicon-'] {
|
||||
.monaco-workbench .chat-pill-icon.codicon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -115,9 +115,9 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The chevron glyph is drawn above the middle of its box, so nudge it onto the
|
||||
label's optical centre. */
|
||||
.monaco-workbench .chat-pill-chevron.codicon[class*='codicon-'] {
|
||||
/* The chevron shares the leading glyph's compact box. `chevron-down-compact` is
|
||||
drawn on that box's centre, so it needs no nudge to sit on the label. */
|
||||
.monaco-workbench .chat-pill-chevron.codicon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -126,7 +126,6 @@
|
||||
margin: 0;
|
||||
font-size: var(--vscode-codiconFontSize-compact);
|
||||
flex-shrink: 0;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* The themed file icon carries its own leading gap, so the pill adds barely any. */
|
||||
|
||||
@@ -176,6 +176,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui
|
||||
content.push(localize('chat.find', 'To search the chat transcript, invoke Find in Chat{0}. Find Next{1} and Find Previous{2} move between results, scrolling each one into view.', '<keybinding:workbench.action.chat.find>', '<keybinding:workbench.action.chat.findNext>', '<keybinding:workbench.action.chat.findPrevious>'));
|
||||
}
|
||||
content.push(localize('chat.attachments.pastedText', "Long pasted text is stored as an attached text item and replaced in the input with a numbered inline reference."));
|
||||
content.push(localize('chat.paste.asText', "To paste the clipboard as plain text, without converting it to Markdown or storing it as an attachment, invoke Paste as Text{0}.", '<keybinding:editor.action.pasteAsText>'));
|
||||
content.push(localize('chat.signals', "Accessibility Signals can be changed via settings with a prefix of signals.chat. By default, if a request takes more than 4 seconds, you will hear a sound indicating that progress is still occurring."));
|
||||
return content.join('\n');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as dom from '../../../../../base/browser/dom.js';
|
||||
import { disposableTimeout } from '../../../../../base/common/async.js';
|
||||
import { IActionRunner } from '../../../../../base/common/actions.js';
|
||||
import { Codicon } from '../../../../../base/common/codicons.js';
|
||||
import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js';
|
||||
import { Disposable, markAsSingleton, MutableDisposable } from '../../../../../base/common/lifecycle.js';
|
||||
import { ThemeIcon } from '../../../../../base/common/themables.js';
|
||||
import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js';
|
||||
@@ -18,6 +19,7 @@ import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../..
|
||||
import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js';
|
||||
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
|
||||
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
|
||||
import { KeybindingsRegistry, KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js';
|
||||
import { IWorkbenchContribution } from '../../../../common/contributions.js';
|
||||
import { katexContainerClassName, katexContainerLatexAttributeName } from '../../../markdown/common/markedKatexExtension.js';
|
||||
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
|
||||
@@ -143,6 +145,15 @@ export class ChatCopyActionRendering extends Disposable implements IWorkbenchCon
|
||||
}
|
||||
|
||||
export function registerChatCopyActions() {
|
||||
// A plain paste in the chat input may become Markdown or an attachment, so
|
||||
// keep the usual "paste without formatting" chord for verbatim text.
|
||||
KeybindingsRegistry.registerKeybindingRule({
|
||||
id: 'editor.action.pasteAsText',
|
||||
weight: KeybindingWeight.WorkbenchContrib,
|
||||
primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyV,
|
||||
when: ChatContextKeys.inputHasFocus,
|
||||
});
|
||||
|
||||
registerAction2(class CopyAllAction extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
|
||||
+70
-18
@@ -56,7 +56,7 @@ import { packErrorForTelemetry } from '../../../../../../platform/telemetry/comm
|
||||
import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js';
|
||||
import { IPathService } from '../../../../../services/path/common/pathService.js';
|
||||
import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js';
|
||||
import { IWorkspaceTrustRequestService } from '../../../../../../platform/workspace/common/workspaceTrust.js';
|
||||
import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../../../platform/workspace/common/workspaceTrust.js';
|
||||
import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js';
|
||||
import { ITerminalChatService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js';
|
||||
import {
|
||||
@@ -1071,6 +1071,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
|
||||
@IAgentHostActiveClientService private readonly _activeClientService: IAgentHostActiveClientService,
|
||||
@IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService,
|
||||
@IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService,
|
||||
@IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService,
|
||||
@IModelService private readonly _modelService: IModelService,
|
||||
@IWorkingCopyService private readonly _workingCopyService: IWorkingCopyService,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@@ -1639,9 +1640,19 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
|
||||
// Gate spawning an agent on workspace trust. Viewing chat and the
|
||||
// agent list does not require trust, but sending a message does, since
|
||||
// the agent reads files, runs commands, and makes changes in the
|
||||
// target folder. Mirrors how extension-host chat is gated. If the user
|
||||
// declines, abort without starting a session.
|
||||
if (!await this._ensureWorkspaceTrust(request.sessionResource)) {
|
||||
// session's folders. Mirrors how extension-host chat is gated. Verify
|
||||
// every local folder the session will run in — an existing session's
|
||||
// persisted working directories, or a new session's requested ones — so
|
||||
// resuming a session whose folder is no longer trusted re-prompts instead
|
||||
// of running untrusted. If the user declines, abort without starting a session.
|
||||
const trustFolders = await this._resolveSessionTrustFolders(request.sessionResource, cancellationToken);
|
||||
if (cancellationToken.isCancellationRequested) {
|
||||
return {};
|
||||
}
|
||||
if (!await this._ensureFoldersTrusted(trustFolders)) {
|
||||
return {};
|
||||
}
|
||||
if (cancellationToken.isCancellationRequested) {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -5509,25 +5520,66 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the workspace/folder the agent will run in is trusted before a
|
||||
* session is spawned. Returns `false` if the user declines.
|
||||
*
|
||||
* When the agent runs inside the currently open workspace (editor window),
|
||||
* gate on workspace trust to match how extension-host chat is gated. When
|
||||
* it targets a standalone folder outside the open workspace (Agents window
|
||||
* per-session folders), gate on that folder's trust instead. Both request
|
||||
* helpers resolve immediately when the target is already trusted, so this
|
||||
* never double-prompts.
|
||||
* Resolves the local folders the agent will run in, for the workspace-trust
|
||||
* gate: an existing session's persisted working directories, or a new session's
|
||||
* requested ones. An explicit empty set (a workspace-less session) is honored;
|
||||
* only a genuinely unresolved set falls back to the requested/workspace folders.
|
||||
*/
|
||||
private async _ensureWorkspaceTrust(sessionResource: URI): Promise<boolean> {
|
||||
const message = localize('agentHost.workspaceTrust', "AI features are currently only supported in trusted workspaces.");
|
||||
const workingDirectory = this._resolveRequestedWorkingDirectory(sessionResource);
|
||||
private async _resolveSessionTrustFolders(sessionResource: URI, token: CancellationToken): Promise<readonly URI[]> {
|
||||
if (!this._isNewSessionResource(sessionResource)) {
|
||||
// Prefer already-hydrated handler-level state; otherwise read the
|
||||
// authoritative eager/connection-level state so the gate checks the
|
||||
// session's real persisted folders, not the current workspace (which,
|
||||
// if it happens to be trusted, would otherwise let an untrusted
|
||||
// persisted folder resume without consent).
|
||||
let dirs = this._existingSessionWorkingDirectories(sessionResource);
|
||||
if (dirs === undefined) {
|
||||
const state = await this._readEagerlyCreatedSessionState(this._resolveSessionUri(sessionResource), token);
|
||||
const persisted = state?.workingDirectories;
|
||||
if (persisted !== undefined) {
|
||||
dirs = persisted.map(directory => typeof directory === 'string' ? URI.parse(directory) : directory);
|
||||
}
|
||||
}
|
||||
if (dirs !== undefined) {
|
||||
return dirs;
|
||||
}
|
||||
}
|
||||
return this._resolveRequestedWorkingDirectories(sessionResource) ?? [];
|
||||
}
|
||||
|
||||
if (!workingDirectory || this._workspaceContextService.getWorkspaceFolder(workingDirectory)) {
|
||||
/**
|
||||
* Ensures every local (file-scheme) folder the agent will run in is trusted
|
||||
* before a session is spawned; returns `false` if the user declines any. Trust
|
||||
* is checked for all folders in parallel and only untrusted folders are prompted
|
||||
* for, one at a time.
|
||||
*/
|
||||
private async _ensureFoldersTrusted(folders: readonly URI[]): Promise<boolean> {
|
||||
const message = localize('agentHost.workspaceTrust', "AI features are currently only supported in trusted workspaces.");
|
||||
const localFolders = folders.filter(folder => folder.scheme === Schemas.file);
|
||||
if (localFolders.length === 0) {
|
||||
return !!await this._workspaceTrustRequestService.requestWorkspaceTrust({ message });
|
||||
}
|
||||
|
||||
return !!await this._workspaceTrustRequestService.requestResourcesTrust({ uri: workingDirectory, message });
|
||||
// Check every folder's trust in parallel so an already-trusted session (the
|
||||
// common case) returns immediately without prompting or sequential awaits.
|
||||
const trustInfos = await Promise.all(localFolders.map(folder => this._workspaceTrustManagementService.getUriTrustInfo(folder)));
|
||||
const untrustedFolders = localFolders.filter((_, index) => !trustInfos[index].trusted);
|
||||
if (untrustedFolders.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prompt for each untrusted folder one at a time (trust dialogs are modal).
|
||||
// A folder in the open workspace is gated via whole-workspace trust (matching
|
||||
// extension-host chat); others via per-resource trust.
|
||||
for (const folder of untrustedFolders) {
|
||||
const trusted = this._workspaceContextService.getWorkspaceFolder(folder)
|
||||
? await this._workspaceTrustRequestService.requestWorkspaceTrust({ message })
|
||||
: await this._workspaceTrustRequestService.requestResourcesTrust({ uri: folder, message });
|
||||
if (!trusted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _convertVariablesToAttachments(request: IChatAgentRequest): MessageAttachment[] {
|
||||
|
||||
@@ -885,6 +885,12 @@ configurationRegistry.registerConfiguration({
|
||||
enum: ['inline', 'hover', 'input', 'none'],
|
||||
default: 'inline',
|
||||
},
|
||||
[ChatConfiguration.PasteAsAttachmentThreshold]: {
|
||||
markdownDescription: nls.localize('chat.pasteAsAttachmentThreshold', "The number of characters a paste must exceed before it is added to the chat input as an attachment instead of being inserted inline. A paste must also span several lines, so a long single-line paste is always inserted inline. Set this to a very large number to always paste inline."),
|
||||
type: 'number',
|
||||
minimum: 0,
|
||||
default: 10000,
|
||||
},
|
||||
[ChatConfiguration.ChatViewSessionsEnabled]: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
|
||||
+21
-38
@@ -18,6 +18,8 @@ import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platf
|
||||
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
|
||||
import { ChatModelFeedbackSurveyStepKind, IChatModelFeedbackSurveyTextStep } from '../../common/feedbackSurvey/chatModelFeedbackSurveyConfig.js';
|
||||
import { IChatResponseViewModel } from '../../common/model/chatViewModel.js';
|
||||
import { CHAT_CARD_HEADER_CLASS, CHAT_CARD_LARGE_CLASS, CHAT_CARD_TITLE_CLASS, createChatCardIconButton } from '../widget/chatCard.js';
|
||||
import { ChatCardListbox } from '../widget/chatCardListbox.js';
|
||||
import { ChatModelFeedbackSurveyStatus, IChatModelFeedbackSurveyService, IChatModelFeedbackSurveyState } from './chatModelFeedbackSurveyService.js';
|
||||
import './media/chatModelFeedbackSurvey.css';
|
||||
|
||||
@@ -149,9 +151,9 @@ export class ChatModelFeedbackSurveyWidget extends Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
const panel = dom.append(this.container, dom.$('.chat-feedback-survey-container'));
|
||||
const header = dom.append(panel, dom.$('.chat-feedback-survey-header'));
|
||||
const title = dom.append(header, dom.$('.chat-feedback-survey-title'));
|
||||
const panel = dom.append(this.container, dom.$(`.chat-feedback-survey-container.${CHAT_CARD_LARGE_CLASS}`));
|
||||
const header = dom.append(panel, dom.$(`.chat-feedback-survey-header.${CHAT_CARD_HEADER_CLASS}`));
|
||||
const title = dom.append(header, dom.$(`.chat-feedback-survey-title.${CHAT_CARD_TITLE_CLASS}`));
|
||||
title.textContent = state.isSubmitted
|
||||
? localize('chat.feedbackSurvey.acknowledgement', "Thanks, your feedback has been recorded.")
|
||||
: step.title;
|
||||
@@ -192,40 +194,22 @@ export class ChatModelFeedbackSurveyWidget extends Disposable {
|
||||
|
||||
private renderCloseButton(header: HTMLElement): Button {
|
||||
const label = localize('chat.feedbackSurvey.dismiss', "Dismiss Survey");
|
||||
const close = this.renderDisposables.add(new Button(header, { ...defaultButtonStyles, secondary: true, supportIcons: true }));
|
||||
close.label = `$(${Codicon.closeSmall.id})`;
|
||||
close.element.classList.add('chat-feedback-survey-close');
|
||||
close.element.setAttribute('aria-label', label);
|
||||
this.renderDisposables.add(this.hoverService.setupDelayedHover(close.element, { content: label }));
|
||||
const close = createChatCardIconButton(this.renderDisposables, header, this.hoverService, {
|
||||
icon: Codicon.closeSmall,
|
||||
ariaLabel: label,
|
||||
hoverContent: label,
|
||||
});
|
||||
this.renderDisposables.add(close.onDidClick(() => this.dismiss()));
|
||||
return close;
|
||||
}
|
||||
|
||||
/** Renders the options as a single select list, matching the ask question tool. */
|
||||
private renderChoiceStep(response: IChatResponseViewModel, body: HTMLElement, instanceId: string, stepId: string, options: readonly { id: string; label: string }[], title: string): HTMLElement {
|
||||
const list = dom.append(body, dom.$('.chat-feedback-survey-list'));
|
||||
list.setAttribute('role', 'listbox');
|
||||
list.setAttribute('aria-label', title);
|
||||
list.tabIndex = 0;
|
||||
|
||||
const items: HTMLElement[] = [];
|
||||
let activeIndex = 0;
|
||||
|
||||
const setActive = (index: number) => {
|
||||
activeIndex = index;
|
||||
items.forEach((item, i) => {
|
||||
const isActive = i === index;
|
||||
item.classList.toggle('active', isActive);
|
||||
item.setAttribute('aria-selected', String(isActive));
|
||||
});
|
||||
list.setAttribute('aria-activedescendant', items[index].id);
|
||||
};
|
||||
const listbox = new ChatCardListbox(dom.append(body, dom.$('.chat-feedback-survey-list')), title, 'active');
|
||||
|
||||
options.forEach((option, index) => {
|
||||
const item = dom.append(list, dom.$('.chat-feedback-survey-list-item'));
|
||||
item.id = `chat-feedback-survey-option-${instanceId}-${stepId}-${index}`;
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('aria-selected', 'false');
|
||||
const item = dom.append(listbox.domNode, dom.$('.chat-feedback-survey-list-item'));
|
||||
listbox.addOption(item, `chat-feedback-survey-${instanceId}-${stepId}`);
|
||||
|
||||
const label = dom.append(item, dom.$('.chat-feedback-survey-list-label'));
|
||||
label.textContent = option.label;
|
||||
@@ -234,32 +218,31 @@ export class ChatModelFeedbackSurveyWidget extends Disposable {
|
||||
dom.EventHelper.stop(e, true);
|
||||
this.surveyService.answerChoice(response, stepId, option.id);
|
||||
}));
|
||||
items.push(item);
|
||||
});
|
||||
|
||||
setActive(0);
|
||||
listbox.setActive(0);
|
||||
|
||||
this.renderDisposables.add(dom.addDisposableListener(list, dom.EventType.KEY_DOWN, e => {
|
||||
this.renderDisposables.add(dom.addDisposableListener(listbox.domNode, dom.EventType.KEY_DOWN, e => {
|
||||
const event = new StandardKeyboardEvent(e);
|
||||
if (event.keyCode === KeyCode.DownArrow) {
|
||||
event.preventDefault();
|
||||
setActive(activeIndex === items.length - 1 ? 0 : activeIndex + 1);
|
||||
listbox.setActive(listbox.wrappedIndex(listbox.activeIndex + 1));
|
||||
} else if (event.keyCode === KeyCode.UpArrow) {
|
||||
event.preventDefault();
|
||||
setActive(activeIndex === 0 ? items.length - 1 : activeIndex - 1);
|
||||
listbox.setActive(listbox.wrappedIndex(listbox.activeIndex - 1));
|
||||
} else if (event.keyCode === KeyCode.Home) {
|
||||
event.preventDefault();
|
||||
setActive(0);
|
||||
listbox.setActive(0);
|
||||
} else if (event.keyCode === KeyCode.End) {
|
||||
event.preventDefault();
|
||||
setActive(items.length - 1);
|
||||
listbox.setActive(listbox.length - 1);
|
||||
} else if (event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) {
|
||||
event.preventDefault();
|
||||
this.surveyService.answerChoice(response, stepId, options[activeIndex].id);
|
||||
this.surveyService.answerChoice(response, stepId, options[listbox.activeIndex].id);
|
||||
}
|
||||
}));
|
||||
|
||||
return list;
|
||||
return listbox.domNode;
|
||||
}
|
||||
|
||||
private renderTextStep(response: IChatResponseViewModel, state: IChatModelFeedbackSurveyState, body: HTMLElement, step: IChatModelFeedbackSurveyTextStep): HTMLElement {
|
||||
|
||||
+1
-53
@@ -3,66 +3,14 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/* Matches the ask question tool so the two inline surfaces read as one family. */
|
||||
/* Card chrome, header, title and the close button come from widget/media/chatCard.css. */
|
||||
|
||||
.chat-feedback-survey-widget.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-feedback-survey-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 8px 0;
|
||||
border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-chat-requestBorder));
|
||||
border-radius: var(--vscode-cornerRadius-large);
|
||||
background-color: var(--vscode-panel-background);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-feedback-survey-container:focus-within {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
/* In the agents window and the editor the surface is the editor background. */
|
||||
.agent-sessions-workbench .chat-feedback-survey-container,
|
||||
.editor-instance .chat-feedback-survey-container {
|
||||
background-color: var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
.chat-feedback-survey-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--vscode-spacing-size80);
|
||||
padding: var(--vscode-spacing-size80) var(--vscode-spacing-size80) var(--vscode-spacing-size80) var(--vscode-spacing-size160);
|
||||
border-bottom: 1px solid var(--vscode-chat-requestBorder);
|
||||
}
|
||||
|
||||
.chat-feedback-survey-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: var(--vscode-fontSize-heading3);
|
||||
font-weight: var(--vscode-fontWeight-semiBold);
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Chrome free, matching the close button on the ask question tool. */
|
||||
.chat-feedback-survey-container .monaco-button.chat-feedback-survey-close {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--vscode-icon-foreground) !important;
|
||||
}
|
||||
|
||||
.chat-feedback-survey-container .monaco-button.chat-feedback-survey-close:hover:not(.disabled) {
|
||||
background: var(--vscode-toolbar-hoverBackground) !important;
|
||||
}
|
||||
|
||||
.chat-feedback-survey-body {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Button, IButtonStyles } from '../../../../../base/browser/ui/button/button.js';
|
||||
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
|
||||
import { ThemeIcon } from '../../../../../base/common/themables.js';
|
||||
import { IHoverService } from '../../../../../platform/hover/browser/hover.js';
|
||||
import './media/chatCard.css';
|
||||
|
||||
/**
|
||||
* The large inline card shell: rounded border, panel background, clipped content. Chat's other
|
||||
* card tier is `.chat-confirmation-widget2`, which is smaller and has no background.
|
||||
*/
|
||||
export const CHAT_CARD_LARGE_CLASS = 'chat-card-large';
|
||||
|
||||
/** Header strip of a large card: title on the left, actions on the right, separated by a rule. */
|
||||
export const CHAT_CARD_HEADER_CLASS = 'chat-card-header';
|
||||
|
||||
export const CHAT_CARD_TITLE_CLASS = 'chat-card-title';
|
||||
|
||||
export const CHAT_CARD_HEADER_ACTIONS_CLASS = 'chat-card-header-actions';
|
||||
|
||||
/**
|
||||
* Button styles that set no colors at all.
|
||||
*
|
||||
* `Button` writes its background, foreground and border as *inline* styles, which no selector can
|
||||
* outrank -- that is why every hand rolled copy of this button needed `!important`. Passing no
|
||||
* colors makes `Button` write empty strings instead, leaving the appearance to the stylesheet.
|
||||
*/
|
||||
export const chatCardButtonStyles: IButtonStyles = {
|
||||
buttonBackground: undefined,
|
||||
buttonHoverBackground: undefined,
|
||||
buttonForeground: undefined,
|
||||
buttonSeparator: undefined,
|
||||
buttonSecondaryBackground: undefined,
|
||||
buttonSecondaryHoverBackground: undefined,
|
||||
buttonSecondaryForeground: undefined,
|
||||
buttonSecondaryBorder: undefined,
|
||||
buttonBorder: undefined,
|
||||
};
|
||||
|
||||
export interface IChatCardIconButtonOptions {
|
||||
/** Omit for buttons whose glyph changes over time; set `label` on the result instead. */
|
||||
readonly icon?: ThemeIcon;
|
||||
readonly ariaLabel: string;
|
||||
/** Adds a delayed hover. Pass the aria label again when the two should match. */
|
||||
readonly hoverContent?: string;
|
||||
/** `strong` reads as content rather than chrome, `padded` sizes to a label. */
|
||||
readonly variant?: 'strong' | 'padded';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a chrome free 22px icon button for a card header or footer.
|
||||
*
|
||||
* Takes the store rather than returning one, so the button and its hover share the caller's
|
||||
* single lifetime.
|
||||
*/
|
||||
export function createChatCardIconButton(store: DisposableStore, container: HTMLElement, hoverService: IHoverService, options: IChatCardIconButtonOptions): Button {
|
||||
const button = store.add(new Button(container, { ...chatCardButtonStyles, secondary: true, supportIcons: true }));
|
||||
button.element.classList.add('chat-card-icon-button');
|
||||
if (options.variant) {
|
||||
button.element.classList.add(`chat-card-icon-button-${options.variant}`);
|
||||
}
|
||||
|
||||
if (options.icon) {
|
||||
button.label = `$(${options.icon.id})`;
|
||||
}
|
||||
|
||||
button.element.setAttribute('aria-label', options.ariaLabel);
|
||||
if (options.hoverContent !== undefined) {
|
||||
store.add(hoverService.setupDelayedHover(button.element, { content: options.hoverContent }));
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* The ARIA scaffolding for the small single select lists inside chat cards.
|
||||
*
|
||||
* Keeps the active row's class, `aria-selected`, and `aria-activedescendant` in agreement, which is
|
||||
* silent to get wrong. Keyboard handling and row rendering stay with the caller, since the
|
||||
* consumers differ on wrapping, digit shortcuts, and what a selection change commits.
|
||||
*/
|
||||
export class ChatCardListbox {
|
||||
|
||||
private readonly options: HTMLElement[] = [];
|
||||
private _activeIndex = -1;
|
||||
|
||||
constructor(
|
||||
readonly domNode: HTMLElement,
|
||||
ariaLabel: string,
|
||||
/** Class toggled on the active row. */
|
||||
private readonly activeClass: string,
|
||||
) {
|
||||
this.domNode.setAttribute('role', 'listbox');
|
||||
this.domNode.setAttribute('aria-label', ariaLabel);
|
||||
this.domNode.tabIndex = 0;
|
||||
}
|
||||
|
||||
get activeIndex(): number {
|
||||
return this._activeIndex;
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.options.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a row as an option. The element is given an id, because
|
||||
* `aria-activedescendant` can only refer to one.
|
||||
*/
|
||||
addOption(element: HTMLElement, idPrefix: string): void {
|
||||
element.id = `${idPrefix}-option-${this.options.length}`;
|
||||
element.setAttribute('role', 'option');
|
||||
element.setAttribute('aria-selected', 'false');
|
||||
this.options.push(element);
|
||||
}
|
||||
|
||||
/** Moves the active option. Pass -1 to clear it, which some callers use for freeform input. */
|
||||
setActive(index: number): void {
|
||||
this._activeIndex = index;
|
||||
this.options.forEach((option, i) => {
|
||||
const isActive = i === index;
|
||||
option.classList.toggle(this.activeClass, isActive);
|
||||
option.setAttribute('aria-selected', String(isActive));
|
||||
});
|
||||
|
||||
const active = this.options[index];
|
||||
if (active) {
|
||||
this.domNode.setAttribute('aria-activedescendant', active.id);
|
||||
} else {
|
||||
this.domNode.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the container. `aria-activedescendant` is only honoured on the element that actually
|
||||
* has DOM focus, so focus must never move into an option or arrowing goes unannounced.
|
||||
*/
|
||||
focus(): void {
|
||||
this.domNode.focus();
|
||||
}
|
||||
|
||||
/** Clamps to the ends, matching the workbench lists. */
|
||||
clampedIndex(index: number): number {
|
||||
return Math.max(0, Math.min(index, this.options.length - 1));
|
||||
}
|
||||
|
||||
/** Wraps around the ends. */
|
||||
wrappedIndex(index: number): number {
|
||||
if (this.options.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
return (index + this.options.length) % this.options.length;
|
||||
}
|
||||
}
|
||||
+10
-9
@@ -27,6 +27,7 @@ import { IContextMenuService } from '../../../../../../platform/contextview/brow
|
||||
import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js';
|
||||
import { FileChangeType, IFileService } from '../../../../../../platform/files/common/files.js';
|
||||
import { IHoverService } from '../../../../../../platform/hover/browser/hover.js';
|
||||
import { CHAT_CARD_LARGE_CLASS, chatCardButtonStyles } from '../chatCard.js';
|
||||
import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js';
|
||||
import { defaultButtonStyles } from '../../../../../../platform/theme/browser/defaultStyles.js';
|
||||
import { IEditorService } from '../../../../../services/editor/common/editorService.js';
|
||||
@@ -145,7 +146,7 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart {
|
||||
// Build DOM that mirrors chat-confirmation-widget2 so we inherit its
|
||||
// styling (title bar, scrollable message, blue/grey button row).
|
||||
const elements = dom.h('.chat-confirmation-widget-container.chat-plan-review-container@container', [
|
||||
dom.h('.chat-confirmation-widget2.chat-plan-review@root', [
|
||||
dom.h(`.chat-confirmation-widget2.chat-plan-review.${CHAT_CARD_LARGE_CLASS}@root`, [
|
||||
dom.h('.chat-confirmation-widget-title.chat-plan-review-title@title', [
|
||||
dom.h('.chat-plan-review-title-content', [
|
||||
dom.h('.chat-plan-review-title-label@titleLabel'),
|
||||
@@ -189,15 +190,15 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart {
|
||||
const reviewButtonTooltip = review.canProvideFeedback
|
||||
? localize('chat.planReview.reviewTooltip', 'Review {0}', fileName)
|
||||
: localize('chat.planReview.openTooltip', 'Open {0}', fileName);
|
||||
const reviewButton = this._register(new Button(this._titleActionsEl, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: reviewButtonTooltip, ariaLabel: reviewButtonTooltip }));
|
||||
reviewButton.element.classList.add('chat-plan-review-title-button', 'chat-plan-review-review-button');
|
||||
const reviewButton = this._register(new Button(this._titleActionsEl, { ...chatCardButtonStyles, secondary: true, supportIcons: true, title: reviewButtonTooltip, ariaLabel: reviewButtonTooltip }));
|
||||
reviewButton.element.classList.add('chat-card-icon-button', 'chat-card-icon-button-padded', 'chat-plan-review-title-button', 'chat-plan-review-review-button');
|
||||
this._reviewButton = reviewButton;
|
||||
this._register(reviewButton.onDidClick(() => void this.enterReviewMode()));
|
||||
}
|
||||
|
||||
// Chevron collapse toggle.
|
||||
this._collapseButton = this._register(new Button(this._titleActionsEl, { ...defaultButtonStyles, secondary: true, supportIcons: true }));
|
||||
this._collapseButton.element.classList.add('chat-plan-review-title-button', 'chat-plan-review-title-icon-button');
|
||||
this._collapseButton = this._register(new Button(this._titleActionsEl, { ...chatCardButtonStyles, secondary: true, supportIcons: true }));
|
||||
this._collapseButton.element.classList.add('chat-card-icon-button', 'chat-card-icon-button-padded', 'chat-plan-review-title-button', 'chat-plan-review-title-icon-button');
|
||||
this._register(this._collapseButton.onDidClick(() => this.toggleCollapsed()));
|
||||
|
||||
// Scrollable message area (markdown).
|
||||
@@ -335,8 +336,8 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart {
|
||||
// Clear All — visibility is toggled with the comments list.
|
||||
if (this.review.planUri) {
|
||||
const clearAllLabel = localize('chat.planReview.clearAll', "Clear All");
|
||||
const clearAllButton = this._register(new Button(headerActions, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: clearAllLabel, ariaLabel: clearAllLabel }));
|
||||
clearAllButton.element.classList.add('chat-plan-review-title-button', 'chat-plan-review-feedback-clear-all');
|
||||
const clearAllButton = this._register(new Button(headerActions, { ...chatCardButtonStyles, secondary: true, supportIcons: true, title: clearAllLabel, ariaLabel: clearAllLabel }));
|
||||
clearAllButton.element.classList.add('chat-card-icon-button', 'chat-card-icon-button-padded', 'chat-plan-review-title-button', 'chat-plan-review-feedback-clear-all');
|
||||
clearAllButton.label = clearAllLabel;
|
||||
this._register(clearAllButton.onDidClick(() => this.clearAllInlineFeedback()));
|
||||
this._clearAllButtonEl = clearAllButton.element;
|
||||
@@ -346,8 +347,8 @@ export class ChatPlanReviewPart extends Disposable implements IChatContentPart {
|
||||
// and Clear All handle deletion explicitly.
|
||||
if (this.review.planUri) {
|
||||
const closeButtonLabel = localize('chat.planReview.close', "Close");
|
||||
const closeButton = this._register(new Button(headerActions, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: closeButtonLabel, ariaLabel: closeButtonLabel }));
|
||||
closeButton.element.classList.add('chat-plan-review-title-button', 'chat-plan-review-title-icon-button', 'chat-plan-review-feedback-close');
|
||||
const closeButton = this._register(new Button(headerActions, { ...chatCardButtonStyles, secondary: true, supportIcons: true, title: closeButtonLabel, ariaLabel: closeButtonLabel }));
|
||||
closeButton.element.classList.add('chat-card-icon-button', 'chat-card-icon-button-padded', 'chat-plan-review-title-button', 'chat-plan-review-title-icon-button', 'chat-plan-review-feedback-close');
|
||||
closeButton.label = `$(${Codicon.closeSmall.id})`;
|
||||
this._register(closeButton.onDidClick(() => this.exitFeedbackMode()));
|
||||
}
|
||||
|
||||
+48
-57
@@ -42,6 +42,8 @@ import { ITerminalChatService } from '../../../../terminal/browser/terminal.js';
|
||||
import { AgentHostAutoReplyAnswer } from '../../../../../../platform/agentHost/common/agentHostSchema.js';
|
||||
import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js';
|
||||
import { getChatMarkdownRenderOptions } from '../chatContentMarkdownRenderer.js';
|
||||
import { CHAT_CARD_HEADER_CLASS, CHAT_CARD_LARGE_CLASS, CHAT_CARD_TITLE_CLASS, createChatCardIconButton } from '../chatCard.js';
|
||||
import { ChatCardListbox } from '../chatCardListbox.js';
|
||||
import './media/chatQuestionCarousel.css';
|
||||
|
||||
const PREVIOUS_QUESTION_ACTION_ID = 'workbench.action.chat.previousQuestion';
|
||||
@@ -166,7 +168,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
) {
|
||||
super();
|
||||
|
||||
this.domNode = dom.$('.chat-question-carousel-container');
|
||||
this.domNode = dom.$(`.chat-question-carousel-container.${CHAT_CARD_LARGE_CLASS}`);
|
||||
this.domNode.classList.toggle('chat-question-carousel-conversation', carousel.answerPresentation === 'conversation');
|
||||
this.domNode.id = generateUuid();
|
||||
this._inChatQuestionCarouselContextKey = ChatContextKeys.inChatQuestionCarousel.bindTo(this._contextKeyService);
|
||||
@@ -232,20 +234,22 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
this._headerActionsContainer = dom.$('.chat-question-header-actions');
|
||||
|
||||
const collapseToggleTitle = localize('chat.questionCarousel.collapseTitle', 'Collapse Questions');
|
||||
const collapseButton = interactiveStore.add(new Button(this._headerActionsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true }));
|
||||
const collapseButton = createChatCardIconButton(interactiveStore, this._headerActionsContainer, this._hoverService, {
|
||||
ariaLabel: collapseToggleTitle,
|
||||
});
|
||||
collapseButton.element.classList.add('chat-question-collapse-toggle');
|
||||
collapseButton.element.setAttribute('aria-label', collapseToggleTitle);
|
||||
this._collapseButton = collapseButton;
|
||||
|
||||
// Close/skip button (X) - placed in header row, only shown when allowSkip is true
|
||||
if (carousel.allowSkip) {
|
||||
this._closeButtonContainer = dom.$('.chat-question-close-container');
|
||||
const skipAllTitle = localize('chat.questionCarousel.skipAllTitle', 'Skip all questions');
|
||||
const skipAllButton = interactiveStore.add(new Button(this._closeButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true }));
|
||||
skipAllButton.label = `$(${Codicon.closeSmall.id})`;
|
||||
const skipAllButton = createChatCardIconButton(interactiveStore, this._closeButtonContainer, this._hoverService, {
|
||||
icon: Codicon.closeSmall,
|
||||
ariaLabel: skipAllTitle,
|
||||
hoverContent: skipAllTitle,
|
||||
});
|
||||
skipAllButton.element.classList.add('chat-question-close');
|
||||
skipAllButton.element.setAttribute('aria-label', skipAllTitle);
|
||||
interactiveStore.add(this._hoverService.setupDelayedHover(skipAllButton.element, { content: skipAllTitle }));
|
||||
this._skipAllButton = skipAllButton;
|
||||
}
|
||||
|
||||
@@ -257,11 +261,12 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
const focusTerminalAriaLabel = kbLabel
|
||||
? localize('chat.questionCarousel.focusTerminalAriaLabel', 'Focus Terminal ({0})', kbLabel)
|
||||
: focusTerminalTitle;
|
||||
const focusTerminalButton = interactiveStore.add(new Button(this._focusTerminalButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true }));
|
||||
focusTerminalButton.label = `$(${Codicon.terminal.id})`;
|
||||
const focusTerminalButton = createChatCardIconButton(interactiveStore, this._focusTerminalButtonContainer, this._hoverService, {
|
||||
icon: Codicon.terminal,
|
||||
ariaLabel: focusTerminalAriaLabel,
|
||||
hoverContent: focusTerminalTitle,
|
||||
});
|
||||
focusTerminalButton.element.classList.add('chat-question-focus-terminal');
|
||||
focusTerminalButton.element.setAttribute('aria-label', focusTerminalAriaLabel);
|
||||
interactiveStore.add(this._hoverService.setupDelayedHover(focusTerminalButton.element, { content: focusTerminalTitle }));
|
||||
interactiveStore.add(focusTerminalButton.onDidClick(() => this._focusTerminal()));
|
||||
|
||||
// Dismiss the carousel when the user types directly in the terminal,
|
||||
@@ -776,7 +781,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
}
|
||||
|
||||
const headerRow = dom.$('.chat-question-header-row');
|
||||
const titleRow = dom.$('.chat-question-title-row');
|
||||
const titleRow = dom.$(`.chat-question-title-row.${CHAT_CARD_HEADER_CLASS}`);
|
||||
|
||||
// Render carousel-level message if present (e.g. from MCP elicitation)
|
||||
if (this.carousel.message && this._currentIndex === 0) {
|
||||
@@ -789,7 +794,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
|
||||
const questionText = getDisplayedQuestionText(question);
|
||||
if (questionText) {
|
||||
const title = dom.$('.chat-question-title');
|
||||
const title = dom.$(`.chat-question-title.${CHAT_CARD_TITLE_CLASS}`);
|
||||
const messageContent = this.getQuestionText(questionText);
|
||||
title.setAttribute('aria-label', messageContent);
|
||||
|
||||
@@ -928,20 +933,24 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
const arrowsContainer = dom.$('.chat-question-nav-arrows');
|
||||
|
||||
const previousLabel = this.getLabelWithKeybinding(localize('previous', 'Previous'), PREVIOUS_QUESTION_ACTION_ID);
|
||||
const prevButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true }));
|
||||
const prevButton = createChatCardIconButton(interactiveStore, arrowsContainer, this._hoverService, {
|
||||
icon: Codicon.chevronLeft,
|
||||
ariaLabel: previousLabel,
|
||||
hoverContent: previousLabel,
|
||||
variant: 'strong',
|
||||
});
|
||||
prevButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-prev');
|
||||
prevButton.label = `$(${Codicon.chevronLeft.id})`;
|
||||
prevButton.element.setAttribute('aria-label', previousLabel);
|
||||
interactiveStore.add(this._hoverService.setupDelayedHover(prevButton.element, { content: previousLabel }));
|
||||
interactiveStore.add(prevButton.onDidClick(() => this.navigate(-1)));
|
||||
this._prevButton = prevButton;
|
||||
|
||||
const nextLabel = this.getLabelWithKeybinding(localize('next', 'Next'), NEXT_QUESTION_ACTION_ID);
|
||||
const nextButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true }));
|
||||
const nextButton = createChatCardIconButton(interactiveStore, arrowsContainer, this._hoverService, {
|
||||
icon: Codicon.chevronRight,
|
||||
ariaLabel: nextLabel,
|
||||
hoverContent: nextLabel,
|
||||
variant: 'strong',
|
||||
});
|
||||
nextButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-next');
|
||||
nextButton.label = `$(${Codicon.chevronRight.id})`;
|
||||
nextButton.element.setAttribute('aria-label', nextLabel);
|
||||
interactiveStore.add(this._hoverService.setupDelayedHover(nextButton.element, { content: nextLabel }));
|
||||
interactiveStore.add(nextButton.onDidClick(() => this.navigate(1)));
|
||||
this._nextButton = nextButton;
|
||||
|
||||
@@ -1123,9 +1132,6 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
private renderSingleSelect(container: HTMLElement, question: IChatQuestion): void {
|
||||
const orderedOptions = getOptionsWithDefaultsFirst(question);
|
||||
const selectContainer = dom.$('.chat-question-list');
|
||||
selectContainer.setAttribute('role', 'listbox');
|
||||
selectContainer.setAttribute('aria-label', question.title);
|
||||
selectContainer.tabIndex = 0;
|
||||
container.appendChild(selectContainer);
|
||||
|
||||
// Restore previous answer if exists
|
||||
@@ -1147,22 +1153,19 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
}
|
||||
});
|
||||
|
||||
const listItems: HTMLElement[] = [];
|
||||
const listbox = new ChatCardListbox(selectContainer, question.title, 'selected');
|
||||
const indicators: HTMLElement[] = [];
|
||||
const updateSelection = (newIndex: number) => {
|
||||
// Update visual state
|
||||
listItems.forEach((item, i) => {
|
||||
/** Paints the row state without committing, which is what initial render needs. */
|
||||
const paintSelection = (newIndex: number) => {
|
||||
listbox.setActive(newIndex);
|
||||
indicators.forEach((indicator, i) => {
|
||||
const isSelected = i === newIndex;
|
||||
item.classList.toggle('selected', isSelected);
|
||||
item.setAttribute('aria-selected', String(isSelected));
|
||||
const indicator = indicators[i];
|
||||
indicator.classList.toggle('codicon', isSelected);
|
||||
indicator.classList.toggle('codicon-check', isSelected);
|
||||
});
|
||||
// Update aria-activedescendant for screen reader announcements
|
||||
if (newIndex >= 0 && newIndex < listItems.length) {
|
||||
selectContainer.setAttribute('aria-activedescendant', listItems[newIndex].id);
|
||||
}
|
||||
};
|
||||
const updateSelection = (newIndex: number) => {
|
||||
paintSelection(newIndex);
|
||||
// Update tracked state
|
||||
const data = this._singleSelectItems.get(question.id);
|
||||
if (data) {
|
||||
@@ -1172,24 +1175,17 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
this.saveCurrentAnswer();
|
||||
};
|
||||
|
||||
const listItems: HTMLElement[] = [];
|
||||
orderedOptions.forEach(({ option }, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const listItem = dom.$('.chat-question-list-item');
|
||||
listItem.setAttribute('role', 'option');
|
||||
listItem.setAttribute('aria-selected', String(isSelected));
|
||||
listbox.addOption(listItem, `option-${question.id}`);
|
||||
listItem.setAttribute('aria-label', localize('chat.questionCarousel.optionLabel', "Option {0}: {1}", index + 1, option.label));
|
||||
listItem.id = `option-${question.id}-${index}`;
|
||||
listItem.tabIndex = -1;
|
||||
|
||||
const number = dom.$('.chat-question-list-number');
|
||||
number.textContent = `${index + 1}`;
|
||||
listItem.appendChild(number);
|
||||
|
||||
// Selection indicator (checkmark when selected)
|
||||
const indicator = dom.$('.chat-question-list-indicator');
|
||||
if (isSelected) {
|
||||
indicator.classList.add('codicon', 'codicon-check');
|
||||
}
|
||||
indicators.push(indicator);
|
||||
|
||||
// Label with optional description (format: "Title - Description")
|
||||
@@ -1210,10 +1206,6 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
listItem.appendChild(label);
|
||||
listItem.appendChild(indicator);
|
||||
|
||||
if (isSelected) {
|
||||
listItem.classList.add('selected');
|
||||
}
|
||||
|
||||
// if we select an option, clear text and go to next question
|
||||
this._inputBoxes.add(dom.addDisposableListener(listItem, dom.EventType.CLICK, (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -1238,10 +1230,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
|
||||
this._singleSelectItems.set(question.id, { items: listItems, selectedIndex, optionIndices: orderedOptions.map(o => o.originalIndex) });
|
||||
|
||||
// Set initial aria-activedescendant if there's a selected item
|
||||
if (selectedIndex >= 0 && selectedIndex < listItems.length) {
|
||||
selectContainer.setAttribute('aria-activedescendant', listItems[selectedIndex].id);
|
||||
}
|
||||
// Paints the initial row and points `aria-activedescendant` at it. Deliberately not
|
||||
// `updateSelection`, which would commit an answer the user has not given yet.
|
||||
paintSelection(selectedIndex);
|
||||
|
||||
// Show freeform input only when explicitly allowed
|
||||
let freeformTextarea: HTMLTextAreaElement | undefined;
|
||||
@@ -1294,10 +1285,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
|
||||
if (event.keyCode === KeyCode.DownArrow) {
|
||||
e.preventDefault();
|
||||
newIndex = Math.min(data.selectedIndex + 1, listItems.length - 1);
|
||||
newIndex = listbox.clampedIndex(data.selectedIndex + 1);
|
||||
} else if (event.keyCode === KeyCode.UpArrow) {
|
||||
e.preventDefault();
|
||||
newIndex = Math.max(data.selectedIndex - 1, 0);
|
||||
newIndex = listbox.clampedIndex(data.selectedIndex - 1);
|
||||
} else if ((event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) && !event.metaKey && !event.ctrlKey) {
|
||||
// Enter confirms current selection and advances to next question
|
||||
e.preventDefault();
|
||||
@@ -1323,7 +1314,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
}
|
||||
}));
|
||||
|
||||
// focus on the row when first rendered or textarea if it has content
|
||||
// Focus the list itself, not an option: `aria-activedescendant` is only honoured on the
|
||||
// focused element. Or the textarea, when it already has content.
|
||||
if (this._shouldAutoFocus()) {
|
||||
if (freeformTextarea && previousFreeform) {
|
||||
const capturedFreeform = freeformTextarea;
|
||||
@@ -1331,13 +1323,12 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent
|
||||
capturedFreeform.focus();
|
||||
}));
|
||||
} else if (listItems.length > 0) {
|
||||
const focusIndex = selectedIndex >= 0 ? selectedIndex : 0;
|
||||
// if no default and no freeform text, select the first answer
|
||||
if (selectedIndex < 0) {
|
||||
updateSelection(0);
|
||||
}
|
||||
this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(selectContainer), () => {
|
||||
listItems[focusIndex]?.focus();
|
||||
listbox.focus();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+1
-27
@@ -38,14 +38,8 @@
|
||||
}
|
||||
|
||||
.interactive-session .chat-plan-review-container > .chat-confirmation-widget2.chat-plan-review {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-chat-requestBorder));
|
||||
border-radius: var(--vscode-cornerRadius-large);
|
||||
background-color: var(--vscode-panel-background);
|
||||
max-height: min(420px, 50vh);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -110,27 +104,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Small, transparent title-bar buttons — matches the question carousel
|
||||
* chat-question-collapse-toggle / chat-question-close styles. */
|
||||
.interactive-session .chat-plan-review-container .monaco-button.chat-plan-review-title-button {
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--vscode-icon-foreground) !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.interactive-session .chat-plan-review-container .monaco-button.chat-plan-review-title-button:hover:not(.disabled) {
|
||||
background: var(--vscode-toolbar-hoverBackground) !important;
|
||||
}
|
||||
|
||||
/* Icon-only square buttons (restore, chevron, edit). Exactly 22x22 with no
|
||||
* padding, matching `.monaco-button.chat-question-collapse-toggle`. */
|
||||
/* Icon-only square buttons (restore, chevron, edit): back to the default 22x22 square. */
|
||||
.interactive-session .chat-plan-review-container .monaco-button.chat-plan-review-title-icon-button {
|
||||
width: 22px;
|
||||
padding: 0;
|
||||
|
||||
+8
-93
@@ -15,15 +15,9 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* general questions styling - matches the tool confirmation (permissions) box family */
|
||||
/* general questions styling - card chrome comes from widget/media/chatCard.css */
|
||||
.interactive-session .chat-question-carousel-container {
|
||||
margin: 8px 0;
|
||||
border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-chat-requestBorder));
|
||||
border-radius: var(--vscode-cornerRadius-large);
|
||||
background-color: var(--vscode-panel-background);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
max-height: min(420px, 45vh);
|
||||
position: relative;
|
||||
@@ -33,17 +27,10 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.interactive-session .chat-question-carousel-container:focus-visible,
|
||||
.interactive-session .chat-question-carousel-container:focus-within {
|
||||
.interactive-session .chat-question-carousel-container:focus-visible {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
/* in the agents window / editor the surface is the editor background */
|
||||
.agent-sessions-workbench .interactive-session .chat-question-carousel-container,
|
||||
.editor-instance .interactive-session .chat-question-carousel-container {
|
||||
background-color: var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
/* input part wrapper */
|
||||
.interactive-session .interactive-input-part > .chat-question-carousel-widget-container,
|
||||
.interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-question-carousel-widget-container {
|
||||
@@ -77,24 +64,12 @@
|
||||
flex-shrink: 0;
|
||||
|
||||
.chat-question-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 8px 8px 8px 16px;
|
||||
border-bottom: 1px solid var(--vscode-chat-requestBorder);
|
||||
}
|
||||
|
||||
.chat-question-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
font-weight: var(--vscode-agents-fontWeight-semiBold);
|
||||
font-size: var(--vscode-agents-fontSize-heading3);
|
||||
margin: 0;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
|
||||
@@ -123,55 +98,10 @@
|
||||
|
||||
.chat-question-focus-terminal-container {
|
||||
flex-shrink: 0;
|
||||
|
||||
.monaco-button.chat-question-focus-terminal {
|
||||
min-width: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--vscode-icon-foreground) !important;
|
||||
}
|
||||
|
||||
.monaco-button.chat-question-focus-terminal:hover:not(.disabled) {
|
||||
background: var(--vscode-toolbar-hoverBackground) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-question-close-container {
|
||||
flex-shrink: 0;
|
||||
|
||||
.monaco-button.chat-question-close {
|
||||
min-width: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--vscode-icon-foreground) !important;
|
||||
}
|
||||
|
||||
.monaco-button.chat-question-close:hover:not(.disabled) {
|
||||
background: var(--vscode-toolbar-hoverBackground) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.monaco-button.chat-question-collapse-toggle {
|
||||
min-width: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--vscode-icon-foreground) !important;
|
||||
}
|
||||
|
||||
.monaco-button.chat-question-collapse-toggle:hover:not(.disabled) {
|
||||
background: var(--vscode-toolbar-hoverBackground) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,8 +235,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* When the question list has focus, use active selection styling */
|
||||
.chat-question-list:focus .chat-question-list-item.selected {
|
||||
/*
|
||||
* When the question list has focus, use active selection styling. Written with `&` because
|
||||
* this rule is nested inside `.chat-question-list`; spelling the class out again would
|
||||
* compile to a list inside a list and never match.
|
||||
*/
|
||||
&:focus .chat-question-list-item.selected {
|
||||
background-color: var(--vscode-list-activeSelectionBackground, var(--vscode-list-hoverBackground));
|
||||
color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground));
|
||||
|
||||
@@ -440,25 +374,6 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.monaco-button.chat-question-nav-arrow {
|
||||
min-width: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--vscode-foreground) !important;
|
||||
}
|
||||
|
||||
.monaco-button.chat-question-nav-arrow:hover:not(.disabled) {
|
||||
background: var(--vscode-toolbar-hoverBackground) !important;
|
||||
}
|
||||
|
||||
.monaco-button.chat-question-nav-arrow.disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.chat-question-step-indicator {
|
||||
font-size: var(--vscode-chat-font-size-body-s);
|
||||
color: var(--vscode-descriptionForeground);
|
||||
|
||||
+2
-71
@@ -19,22 +19,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Card chrome comes from widget/media/chatCard.css. */
|
||||
.chat-tool-confirmation-carousel {
|
||||
color: var(--vscode-foreground);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(300px, 45vh);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: var(--vscode-cornerRadius-large);
|
||||
background-color: var(--vscode-panel-background);
|
||||
overflow: hidden;
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
&:focus-visible,
|
||||
&:focus-within {
|
||||
&:focus-visible {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
@@ -99,32 +93,9 @@
|
||||
}
|
||||
|
||||
.chat-tool-carousel-overlay-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.monaco-button.chat-tool-carousel-dismiss-button {
|
||||
min-width: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--vscode-icon-foreground) !important;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.monaco-button.chat-tool-carousel-dismiss-button:hover {
|
||||
background: var(--vscode-toolbar-hoverBackground) !important;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
.monaco-button:focus {
|
||||
outline: none !important;
|
||||
@@ -136,44 +107,6 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.monaco-button.chat-tool-carousel-nav-arrow {
|
||||
min-width: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--vscode-foreground) !important;
|
||||
}
|
||||
|
||||
.monaco-button.chat-tool-carousel-nav-arrow:hover:not(.disabled) {
|
||||
background: var(--vscode-toolbar-hoverBackground) !important;
|
||||
}
|
||||
|
||||
.monaco-button.chat-tool-carousel-nav-arrow.disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.monaco-button.chat-tool-carousel-header-button {
|
||||
min-width: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--vscode-icon-foreground) !important;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.monaco-button.chat-tool-carousel-header-button:hover:not(.disabled) {
|
||||
background: var(--vscode-toolbar-hoverBackground) !important;
|
||||
}
|
||||
|
||||
&.chat-tool-carousel-content-expanded {
|
||||
max-height: min(650px, 70vh);
|
||||
}
|
||||
@@ -311,8 +244,6 @@
|
||||
|
||||
.agent-sessions-workbench .chat-tool-confirmation-carousel,
|
||||
.editor-instance .chat-tool-confirmation-carousel {
|
||||
background-color: var(--vscode-editor-background);
|
||||
|
||||
.interactive-result-editor {
|
||||
background-color: var(--vscode-interactive-result-editor-background-color, var(--vscode-editor-background));
|
||||
}
|
||||
|
||||
+11
-10
@@ -15,6 +15,7 @@ import { autorun } from '../../../../../../../base/common/observable.js';
|
||||
import { generateUuid } from '../../../../../../../base/common/uuid.js';
|
||||
import { localize } from '../../../../../../../nls.js';
|
||||
import { defaultButtonStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js';
|
||||
import { CHAT_CARD_HEADER_ACTIONS_CLASS, CHAT_CARD_LARGE_CLASS, chatCardButtonStyles } from '../../chatCard.js';
|
||||
import { IChatToolInvocation, ToolConfirmKind } from '../../../../common/chatService/chatService.js';
|
||||
import { ChatToolInvocationPart } from './chatToolInvocationPart.js';
|
||||
import '../media/chatToolConfirmationCarousel.css';
|
||||
@@ -79,13 +80,13 @@ export class ChatToolConfirmationCarouselPart extends Disposable {
|
||||
) {
|
||||
super();
|
||||
|
||||
const elements = dom.h('.chat-tool-confirmation-carousel@root', [
|
||||
const elements = dom.h(`.chat-tool-confirmation-carousel.${CHAT_CARD_LARGE_CLASS}@root`, [
|
||||
dom.h('.chat-tool-carousel-overlay@overlay', [
|
||||
dom.h('.chat-tool-carousel-title-group@titleGroup', [
|
||||
dom.h('span.chat-tool-carousel-collapsed-title@collapsedTitle'),
|
||||
dom.h('button.chat-tool-carousel-agent-label@agentLabel'),
|
||||
]),
|
||||
dom.h('.chat-tool-carousel-overlay-actions@overlayActions', [
|
||||
dom.h(`.chat-tool-carousel-overlay-actions.${CHAT_CARD_HEADER_ACTIONS_CLASS}@overlayActions`, [
|
||||
dom.h('.chat-tool-carousel-step-indicator@stepIndicator'),
|
||||
dom.h('.chat-tool-carousel-nav-arrows@navArrows'),
|
||||
]),
|
||||
@@ -112,15 +113,15 @@ export class ChatToolConfirmationCarouselPart extends Disposable {
|
||||
this.allowAllButton.label = localize('allowAll', "Allow All");
|
||||
this._register(this.allowAllButton.onDidClick(() => this.allowAll()));
|
||||
|
||||
this.expandContentButton = this._register(new Button(elements.overlayActions, { ...defaultButtonStyles, secondary: true, supportIcons: true }));
|
||||
this.expandContentButton.element.classList.add('chat-tool-carousel-header-button', 'chat-tool-carousel-expand-content-button');
|
||||
this.expandContentButton = this._register(new Button(elements.overlayActions, { ...chatCardButtonStyles, secondary: true, supportIcons: true }));
|
||||
this.expandContentButton.element.classList.add('chat-card-icon-button', 'chat-tool-carousel-header-button', 'chat-tool-carousel-expand-content-button');
|
||||
this.expandContentButton.element.setAttribute('aria-controls', this.contentContainer.id);
|
||||
this.updateExpandContentButton();
|
||||
dom.hide(this.expandContentButton.element);
|
||||
this._register(this.expandContentButton.onDidClick(() => this.toggleContentExpanded()));
|
||||
|
||||
this.dismissButton = this._register(new Button(elements.overlayActions, { ...defaultButtonStyles, secondary: true, supportIcons: true }));
|
||||
this.dismissButton.element.classList.add('chat-tool-carousel-dismiss-button');
|
||||
this.dismissButton = this._register(new Button(elements.overlayActions, { ...chatCardButtonStyles, secondary: true, supportIcons: true }));
|
||||
this.dismissButton.element.classList.add('chat-card-icon-button', 'chat-tool-carousel-dismiss-button');
|
||||
this.dismissButton.label = `$(${Codicon.closeSmall.id})`;
|
||||
const dismissButtonLabel = this.items.length === 1
|
||||
? localize('skip', "Skip")
|
||||
@@ -130,21 +131,21 @@ export class ChatToolConfirmationCarouselPart extends Disposable {
|
||||
this._register(this.dismissButton.onDidClick(() => this.skipAll()));
|
||||
|
||||
this.prevButton = this._register(new Button(elements.navArrows, {
|
||||
...defaultButtonStyles,
|
||||
...chatCardButtonStyles,
|
||||
secondary: true,
|
||||
supportIcons: true,
|
||||
}));
|
||||
this.prevButton.element.classList.add('chat-tool-carousel-nav-arrow');
|
||||
this.prevButton.element.classList.add('chat-card-icon-button', 'chat-card-icon-button-strong', 'chat-tool-carousel-nav-arrow');
|
||||
this.prevButton.label = `$(${Codicon.chevronLeft.id})`;
|
||||
this.prevButton.element.setAttribute('aria-label', localize('previous', "Previous"));
|
||||
this._register(this.prevButton.onDidClick(() => this.navigateRelative(-1)));
|
||||
|
||||
this.nextButton = this._register(new Button(elements.navArrows, {
|
||||
...defaultButtonStyles,
|
||||
...chatCardButtonStyles,
|
||||
secondary: true,
|
||||
supportIcons: true,
|
||||
}));
|
||||
this.nextButton.element.classList.add('chat-tool-carousel-nav-arrow');
|
||||
this.nextButton.element.classList.add('chat-card-icon-button', 'chat-card-icon-button-strong', 'chat-tool-carousel-nav-arrow');
|
||||
this.nextButton.label = `$(${Codicon.chevronRight.id})`;
|
||||
this.nextButton.element.setAttribute('aria-label', localize('next', "Next"));
|
||||
this._register(this.nextButton.onDidClick(() => this.navigateRelative(1)));
|
||||
|
||||
@@ -26,17 +26,24 @@ import { localize } from '../../../../../../../nls.js';
|
||||
import { IEnvironmentService } from '../../../../../../../platform/environment/common/environment.js';
|
||||
import { IFileService } from '../../../../../../../platform/files/common/files.js';
|
||||
import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js';
|
||||
import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js';
|
||||
import { ILogService } from '../../../../../../../platform/log/common/log.js';
|
||||
import { IExtensionService, isProposedApiEnabled } from '../../../../../../services/extensions/common/extensions.js';
|
||||
import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, isImageVariableEntry, toPasteVariableEntry, ChatPasteAttachmentMetadata } from '../../../../common/attachments/chatVariableEntries.js';
|
||||
import { chatVariableLeader } from '../../../../common/requestParser/chatParserTypes.js';
|
||||
import { IDynamicVariable } from '../../../../common/attachments/chatVariables.js';
|
||||
import { IChatPasteTarget, IChatPasteTargetService } from '../../../chat.js';
|
||||
import { chatInputSchemes, isChatInputModel } from '../../../../common/constants.js';
|
||||
import { chatInputSchemes, isChatInputModel, ChatConfiguration } from '../../../../common/constants.js';
|
||||
import { cleanupOldImages, createFileForMedia, resizeImage } from '../../../chatImageUtils.js';
|
||||
|
||||
const COPY_MIME_TYPES = 'application/vnd.code.additional-editor-data';
|
||||
const pastedTextArtifactMinLength = 1000;
|
||||
export const pastedTextArtifactDefaultMinLength = 10000;
|
||||
/**
|
||||
* A long single line, such as a URL, a stack frame, or a dictated sentence, is
|
||||
* content the user means to write with, so length alone must not turn it into
|
||||
* an attachment. Only text that is also shaped like a document qualifies.
|
||||
*/
|
||||
const pastedTextArtifactMinLines = 10;
|
||||
export const CHAT_ATTACHMENT_MIME_TYPE = 'application/vnd.chat.attachment+json';
|
||||
|
||||
interface SerializedCopyData {
|
||||
@@ -345,6 +352,7 @@ export class PasteTextProvider implements DocumentPasteEditProvider {
|
||||
private readonly pasteTargetService: IChatPasteTargetService,
|
||||
private readonly modelService: IModelService,
|
||||
private readonly logService: ILogService,
|
||||
private readonly configurationService: IConfigurationService,
|
||||
) { }
|
||||
|
||||
async provideDocumentPasteEdits(model: ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, _context: DocumentPasteContext, token: CancellationToken): Promise<DocumentPasteEditsSession | undefined> {
|
||||
@@ -400,7 +408,10 @@ export class PasteTextProvider implements DocumentPasteEditProvider {
|
||||
if (token.isCancellationRequested) {
|
||||
return;
|
||||
}
|
||||
const artifact = hasRicherPaste ? undefined : createPastedTextArtifact(textdata, target.attachments, markdown);
|
||||
const artifact = hasRicherPaste ? undefined : createPastedTextArtifact(textdata, target.attachments, {
|
||||
content: markdown,
|
||||
minLength: this.configurationService.getValue<number>(ChatConfiguration.PasteAsAttachmentThreshold, { resource: model.uri }),
|
||||
});
|
||||
if (artifact) {
|
||||
if (ranges.length !== 1 || target.isTerminalCommandPaste(textdata, ranges[0])) {
|
||||
return;
|
||||
@@ -448,10 +459,16 @@ export class PasteTextProvider implements DocumentPasteEditProvider {
|
||||
export function createPastedTextArtifact(
|
||||
text: string,
|
||||
existingAttachments: readonly IChatRequestVariableEntry[],
|
||||
/** Richer representation to store instead of `text`, e.g. Markdown from pasted HTML. */
|
||||
content?: string,
|
||||
options?: {
|
||||
/** Richer representation to store instead of `text`, e.g. Markdown from pasted HTML. */
|
||||
readonly content?: string;
|
||||
/** Character count the paste must exceed to become an attachment. */
|
||||
readonly minLength?: number;
|
||||
},
|
||||
): { readonly attachment: IChatRequestPasteVariableEntry; readonly referenceText: string } | undefined {
|
||||
if (text.trim().length < pastedTextArtifactMinLength) {
|
||||
const trimmed = text.trim();
|
||||
const minLength = options?.minLength ?? pastedTextArtifactDefaultMinLength;
|
||||
if (trimmed.length < minLength || countLines(trimmed) < pastedTextArtifactMinLines) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -461,8 +478,9 @@ export function createPastedTextArtifact(
|
||||
name = localize('pastedTextArtifact.name', "Pasted text #{0}", index++);
|
||||
} while (existingAttachments.some(attachment => attachment.name === name));
|
||||
|
||||
const content = options?.content;
|
||||
const value = content ?? text;
|
||||
const lineCount = value.split(/\r\n|\r|\n/).length;
|
||||
const lineCount = countLines(value);
|
||||
const pastedLines = lineCount === 1
|
||||
? localize('pastedTextArtifact.oneLine', "1 line")
|
||||
: localize('pastedTextArtifact.multipleLines', "{0} lines", lineCount);
|
||||
@@ -479,6 +497,10 @@ export function createPastedTextArtifact(
|
||||
};
|
||||
}
|
||||
|
||||
function countLines(value: string): number {
|
||||
return value.split(/\r\n|\r|\n/).length;
|
||||
}
|
||||
|
||||
function getCopiedContext(code: string, file: URI, language: string, range: IRange): IChatRequestPasteVariableEntry {
|
||||
const fileName = basename(file);
|
||||
const start = range.startLineNumber;
|
||||
@@ -846,12 +868,13 @@ export class ChatPasteProvidersFeature extends Disposable {
|
||||
@IModelService modelService: IModelService,
|
||||
@IEnvironmentService environmentService: IEnvironmentService,
|
||||
@ILogService logService: ILogService,
|
||||
@IConfigurationService configurationService: IConfigurationService,
|
||||
) {
|
||||
super();
|
||||
const chatInputProviders: DocumentPasteEditProvider[] = [
|
||||
instaService.createInstance(CopyAttachmentsProvider),
|
||||
new PasteImageProvider(pasteTargetService, extensionService, fileService, environmentService, logService),
|
||||
new PasteTextProvider(pasteTargetService, modelService, logService),
|
||||
new PasteTextProvider(pasteTargetService, modelService, logService, configurationService),
|
||||
new PasteHtmlProvider(),
|
||||
];
|
||||
for (const scheme of chatInputSchemes) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/*
|
||||
* Shared chrome for the large inline cards in chat: the question carousel, the model feedback
|
||||
* survey, the tool confirmation carousel, and plan review.
|
||||
*
|
||||
* Chat has two card tiers. `.chat-confirmation-widget2` is the medium tier (medium radius,
|
||||
* request border, no background). This file is the large tier, which was open coded in all four
|
||||
* places above before it had a name.
|
||||
*
|
||||
* Rules here are deliberately unscoped. The consumers sit under different ancestors (the survey
|
||||
* is not inside `.interactive-session` at all), and the styles they replace are being deleted in
|
||||
* the same change, so there is nothing left to outrank.
|
||||
*/
|
||||
|
||||
.chat-card-large {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-chat-requestBorder));
|
||||
border-radius: var(--vscode-cornerRadius-large);
|
||||
background-color: var(--vscode-panel-background);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-card-large:focus-within {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
/*
|
||||
* Plan review composes both tiers on one element: it is a confirmation widget presented as a large
|
||||
* card. The two tier classes have equal specificity, so name them together to state which tier owns
|
||||
* the shell rather than leaving it to the order the stylesheets happen to load in.
|
||||
*/
|
||||
.chat-card-large.chat-confirmation-widget2 {
|
||||
border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-chat-requestBorder));
|
||||
border-radius: var(--vscode-cornerRadius-large);
|
||||
}
|
||||
|
||||
.chat-card-large.chat-confirmation-widget2:focus-within {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
/* In the agents window and the editor the surface is the editor background. */
|
||||
.agent-sessions-workbench .chat-card-large,
|
||||
.editor-instance .chat-card-large {
|
||||
background-color: var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
.chat-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--vscode-spacing-size80);
|
||||
padding: var(--vscode-spacing-size80) var(--vscode-spacing-size80) var(--vscode-spacing-size80) var(--vscode-spacing-size160);
|
||||
border-bottom: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-card-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: var(--vscode-fontSize-heading3);
|
||||
font-weight: var(--vscode-fontWeight-semiBold);
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chat-card-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--vscode-spacing-size40);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Chrome free icon buttons. `Button` writes its colors as inline styles, so these are only
|
||||
* reachable from CSS when the button is built without color options -- see
|
||||
* `chatCardButtonStyles` in chatCard.ts. That is why no rule here needs `!important`.
|
||||
*/
|
||||
.monaco-button.chat-card-icon-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: var(--vscode-icon-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.monaco-button.chat-card-icon-button:hover:not(.disabled) {
|
||||
background: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
|
||||
.monaco-button.chat-card-icon-button.disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Navigation arrows read as content rather than chrome, so they take the regular foreground. */
|
||||
.monaco-button.chat-card-icon-button.chat-card-icon-button-strong {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
/* For labelled title bar buttons, which size to their content instead of a fixed square. */
|
||||
.monaco-button.chat-card-icon-button.chat-card-icon-button-padded {
|
||||
width: auto;
|
||||
padding: 0 var(--vscode-spacing-size60);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export enum ChatConfiguration {
|
||||
ExtensionToolsEnabled = 'chat.extensionTools.enabled',
|
||||
RepoInfoEnabled = 'chat.repoInfo.enabled',
|
||||
EditRequests = 'chat.editRequests',
|
||||
PasteAsAttachmentThreshold = 'chat.pasteAsAttachmentThreshold',
|
||||
InlineReferencesStyle = 'chat.inlineReferences.style',
|
||||
AutoReply = 'chat.autoReply',
|
||||
GlobalAutoApprove = 'chat.tools.global.autoApprove',
|
||||
|
||||
+128
-3
@@ -62,7 +62,7 @@ import { IPathService } from '../../../../../services/path/common/pathService.js
|
||||
import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
|
||||
import { IOutputService } from '../../../../../services/output/common/output.js';
|
||||
import { IWorkspaceContextService, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js';
|
||||
import { IWorkspaceTrustRequestService } from '../../../../../../platform/workspace/common/workspaceTrust.js';
|
||||
import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService, ResourceTrustRequestOptions } from '../../../../../../platform/workspace/common/workspaceTrust.js';
|
||||
import { AgentHostContribution, AgentHostSessionHandler } from '../../../browser/agentSessions/agentHost/agentHostChatContribution.js';
|
||||
import { AgentHostAuthTokenCache } from '../../../browser/agentSessions/agentHost/agentHostAuth.js';
|
||||
import { AgentHostLanguageModelProvider } from '../../../browser/agentSessions/agentHost/agentHostLanguageModelProvider.js';
|
||||
@@ -788,17 +788,23 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv
|
||||
},
|
||||
onDidChangeWorkspaceFolders: Event.None
|
||||
});
|
||||
const trustController: { result: boolean | undefined; workspaceTrustCalls: number; resourcesTrustCalls: number } = { result: true, workspaceTrustCalls: 0, resourcesTrustCalls: 0 };
|
||||
const trustController: { result: boolean | undefined; workspaceTrustCalls: number; resourcesTrustCalls: number; resourcesTrustUris: URI[]; trustedUris: Set<string> } = { result: true, workspaceTrustCalls: 0, resourcesTrustCalls: 0, resourcesTrustUris: [], trustedUris: new Set<string>() };
|
||||
instantiationService.stub(IWorkspaceTrustRequestService, new class extends mock<IWorkspaceTrustRequestService>() {
|
||||
override async requestWorkspaceTrust(): Promise<boolean | undefined> {
|
||||
trustController.workspaceTrustCalls++;
|
||||
return trustController.result;
|
||||
}
|
||||
override async requestResourcesTrust(): Promise<boolean | undefined> {
|
||||
override async requestResourcesTrust(options: ResourceTrustRequestOptions): Promise<boolean | undefined> {
|
||||
trustController.resourcesTrustCalls++;
|
||||
trustController.resourcesTrustUris.push(options.uri);
|
||||
return trustController.result;
|
||||
}
|
||||
});
|
||||
instantiationService.stub(IWorkspaceTrustManagementService, new class extends mock<IWorkspaceTrustManagementService>() {
|
||||
override async getUriTrustInfo(uri: URI) {
|
||||
return { uri, trusted: trustController.trustedUris.has(uri.toString()) };
|
||||
}
|
||||
});
|
||||
instantiationService.stub(IChatEditingService, {
|
||||
registerEditingSessionProvider: () => toDisposable(() => { }),
|
||||
});
|
||||
@@ -4113,6 +4119,125 @@ suite('AgentHostChatContribution', () => {
|
||||
assert.strictEqual(agentHostService.createSessionCalls.length, 1);
|
||||
assert.strictEqual(trustController.workspaceTrustCalls + trustController.resourcesTrustCalls, 1);
|
||||
}));
|
||||
|
||||
test('sends without prompting when all session folders are already trusted', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
|
||||
const trustedFolder = URI.file('/repo-trusted');
|
||||
const { sessionHandler, agentHostService, chatAgentService, trustController } = createContribution(disposables, { workspaceFolders: [trustedFolder] });
|
||||
trustController.trustedUris.add(trustedFolder.toString());
|
||||
|
||||
const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'Hi' });
|
||||
fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction);
|
||||
await turnPromise;
|
||||
|
||||
// The folder is already trusted, so the parallel trust check short-circuits
|
||||
// and the turn proceeds without any trust prompt.
|
||||
assert.deepStrictEqual({
|
||||
prompts: trustController.workspaceTrustCalls + trustController.resourcesTrustCalls,
|
||||
created: agentHostService.createSessionCalls.length,
|
||||
}, {
|
||||
prompts: 0,
|
||||
created: 1,
|
||||
});
|
||||
}));
|
||||
|
||||
test('resuming an existing session gates on its persisted folder and aborts on decline', async () => {
|
||||
const { sessionHandler, agentHostService, chatAgentService, trustController } = createContribution(disposables);
|
||||
trustController.result = false;
|
||||
|
||||
// Seed an existing (non-new) session whose persisted working directory is
|
||||
// an untrusted local folder, then hydrate it via provideChatSessionContent.
|
||||
const untrustedFolder = URI.file('/repo-a');
|
||||
const backendSession = AgentSession.uri('copilot', 'resumed-x');
|
||||
agentHostService.sessionStates.set(backendSession.toString(), {
|
||||
...createSessionState({
|
||||
resource: backendSession.toString(),
|
||||
provider: 'copilot',
|
||||
title: 'Resumed',
|
||||
status: SessionStatus.Idle,
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString(),
|
||||
workingDirectories: [untrustedFolder.toString()],
|
||||
}),
|
||||
lifecycle: SessionLifecycle.Ready,
|
||||
activeClients: [],
|
||||
});
|
||||
agentHostService.addSession({ session: backendSession, startTime: 1000, modifiedTime: 2000, summary: 'Resumed', workingDirectories: [untrustedFolder] });
|
||||
|
||||
const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/resumed-x' });
|
||||
const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None);
|
||||
disposables.add(toDisposable(() => chatSession.dispose()));
|
||||
|
||||
const registered = chatAgentService.registeredAgents.get('agent-host-copilot')!;
|
||||
const result = await registered.impl.invoke(
|
||||
makeRequest({ message: 'Hello', sessionResource }),
|
||||
() => { }, [], CancellationToken.None,
|
||||
);
|
||||
|
||||
// Gated on the session's own folder (resource trust), not the trivially
|
||||
// satisfied whole-workspace fallback, and aborted without spawning.
|
||||
assert.deepStrictEqual({
|
||||
result,
|
||||
created: agentHostService.createSessionCalls.length,
|
||||
resourcesTrustCalls: trustController.resourcesTrustCalls,
|
||||
workspaceTrustCalls: trustController.workspaceTrustCalls,
|
||||
}, {
|
||||
result: {},
|
||||
created: 0,
|
||||
resourcesTrustCalls: 1,
|
||||
workspaceTrustCalls: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('sending to an unopened existing session gates on its persisted folder, not the current workspace', async () => {
|
||||
const { agentHostService, chatAgentService, trustController } = createContribution(disposables);
|
||||
trustController.result = false;
|
||||
|
||||
// An existing session whose persisted working directory is an untrusted
|
||||
// local folder, eager-created (connection-level state open) but never
|
||||
// opened in this window (no provideChatSessionContent), so the handler
|
||||
// has no hydrated subscription. The gate must still read the persisted
|
||||
// folder rather than falling back to the (trivially trusted) workspace.
|
||||
const untrustedFolder = URI.file('/repo-unopened');
|
||||
const backendSession = AgentSession.uri('copilot', 'unopened-x');
|
||||
agentHostService.sessionStates.set(backendSession.toString(), {
|
||||
...createSessionState({
|
||||
resource: backendSession.toString(),
|
||||
provider: 'copilot',
|
||||
title: 'Unopened',
|
||||
status: SessionStatus.Idle,
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString(),
|
||||
workingDirectories: [untrustedFolder.toString()],
|
||||
}),
|
||||
lifecycle: SessionLifecycle.Ready,
|
||||
activeClients: [],
|
||||
});
|
||||
agentHostService.addSession({ session: backendSession, startTime: 1000, modifiedTime: 2000, summary: 'Unopened', workingDirectories: [untrustedFolder] });
|
||||
// Hold the connection-level subscription open (mimics the eager holder)
|
||||
// so `_readEagerlyCreatedSessionState` can peek the persisted state.
|
||||
disposables.add(agentHostService.getSubscription(StateComponents.Session, backendSession));
|
||||
|
||||
const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/unopened-x' });
|
||||
const registered = chatAgentService.registeredAgents.get('agent-host-copilot')!;
|
||||
const result = await registered.impl.invoke(
|
||||
makeRequest({ message: 'Hello', sessionResource }),
|
||||
() => { }, [], CancellationToken.None,
|
||||
);
|
||||
|
||||
// Prompted on the persisted folder (resource trust), not the trivially
|
||||
// satisfied whole-workspace fallback, and aborted without spawning.
|
||||
assert.deepStrictEqual({
|
||||
result,
|
||||
created: agentHostService.createSessionCalls.length,
|
||||
resourcesTrustUris: trustController.resourcesTrustUris.map(uri => uri.toString()),
|
||||
workspaceTrustCalls: trustController.workspaceTrustCalls,
|
||||
}, {
|
||||
result: {},
|
||||
created: 0,
|
||||
resourcesTrustUris: [untrustedFolder.toString()],
|
||||
workspaceTrustCalls: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- "Preparing session…" migration status --------------------------
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ suite('ChatModelFeedbackSurveyWidget', () => {
|
||||
assert.deepStrictEqual({ initial, afterDown, activeDescendant: list.getAttribute('aria-activedescendant') }, {
|
||||
initial: ['true', 'false', 'false'],
|
||||
afterDown: ['false', 'true', 'false'],
|
||||
activeDescendant: 'chat-feedback-survey-option-instance-1-routing-1',
|
||||
activeDescendant: 'chat-feedback-survey-instance-1-routing-option-1',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+113
@@ -715,6 +715,119 @@ suite('ChatQuestionCarouselPart', () => {
|
||||
});
|
||||
});
|
||||
|
||||
suite('Single Select Keyboard Navigation', () => {
|
||||
function createSelectWidget(optionCount: number = 3, allowFreeformInput: boolean = true) {
|
||||
const options = Array.from({ length: optionCount }, (_, i) => ({
|
||||
id: String.fromCharCode(97 + i),
|
||||
label: `Option ${String.fromCharCode(65 + i)}`,
|
||||
value: String.fromCharCode(97 + i),
|
||||
}));
|
||||
createWidget(createMockCarousel([{ id: 'q1', type: 'singleSelect', title: 'Choose one', options, allowFreeformInput }]));
|
||||
return widget.domNode.querySelector('.chat-question-list') as HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* `keyCode` is a legacy read-only property. Chromium does accept it in the init dict, but
|
||||
* that is non-standard and would need a cast, so define it explicitly as the survey test
|
||||
* helper does. `StandardKeyboardEvent` reads it to derive its own key code.
|
||||
*/
|
||||
function press(target: HTMLElement, keyCode: number, key: string): void {
|
||||
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'keyCode', { get: () => keyCode });
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/** The option index the list reports as selected, via the class the styling keys off. */
|
||||
function selectedIndex(): number {
|
||||
const items = [...widget.domNode.querySelectorAll('.chat-question-list-item')];
|
||||
return items.findIndex(i => i.classList.contains('selected'));
|
||||
}
|
||||
|
||||
/** The option index `aria-activedescendant` points at, which is what a screen reader reads. */
|
||||
function activeDescendantIndex(list: HTMLElement): number {
|
||||
const id = list.getAttribute('aria-activedescendant');
|
||||
const items = [...widget.domNode.querySelectorAll('.chat-question-list-item')];
|
||||
return items.findIndex(i => i.id === id);
|
||||
}
|
||||
|
||||
test('arrow keys move the selection and clamp at both ends', () => {
|
||||
const list = createSelectWidget(3);
|
||||
|
||||
const start = selectedIndex();
|
||||
press(list, 40 /* DownArrow */, 'ArrowDown');
|
||||
const afterDown = selectedIndex();
|
||||
press(list, 38 /* UpArrow */, 'ArrowUp');
|
||||
press(list, 38 /* UpArrow */, 'ArrowUp');
|
||||
const clampedAtTop = selectedIndex();
|
||||
press(list, 40 /* DownArrow */, 'ArrowDown');
|
||||
press(list, 40 /* DownArrow */, 'ArrowDown');
|
||||
press(list, 40 /* DownArrow */, 'ArrowDown');
|
||||
const clampedAtBottom = selectedIndex();
|
||||
|
||||
assert.deepStrictEqual({ start, afterDown, clampedAtTop, clampedAtBottom }, {
|
||||
start: 0,
|
||||
afterDown: 1,
|
||||
clampedAtTop: 0,
|
||||
clampedAtBottom: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('number keys select the matching option, and the one past the last focuses freeform', () => {
|
||||
const list = createSelectWidget(3);
|
||||
|
||||
press(list, 51 /* Digit3 */, '3');
|
||||
const afterDigit3 = selectedIndex();
|
||||
press(list, 52 /* Digit4 */, '4');
|
||||
const afterDigitPastEnd = selectedIndex();
|
||||
const freeform = widget.domNode.querySelector('.chat-question-freeform-textarea');
|
||||
|
||||
assert.deepStrictEqual({ afterDigit3, afterDigitPastEnd, freeformFocused: mainWindow.document.activeElement === freeform }, {
|
||||
afterDigit3: 2,
|
||||
afterDigitPastEnd: -1,
|
||||
freeformFocused: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('aria-activedescendant follows the selection', () => {
|
||||
const list = createSelectWidget(3);
|
||||
|
||||
const initial = activeDescendantIndex(list);
|
||||
press(list, 40 /* DownArrow */, 'ArrowDown');
|
||||
const afterDown = activeDescendantIndex(list);
|
||||
|
||||
assert.deepStrictEqual({ initial, afterDown, matchesSelection: afterDown === selectedIndex() }, {
|
||||
initial: 0,
|
||||
afterDown: 1,
|
||||
matchesSelection: true,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `aria-activedescendant` is only honoured on the element that actually has DOM focus. The
|
||||
* list declares it, so the list is what has to be focused for the active option to be
|
||||
* announced as the user arrows through the options.
|
||||
*/
|
||||
test('auto focus lands on the listbox that owns aria-activedescendant', async () => {
|
||||
const list = createSelectWidget(3);
|
||||
await new Promise<void>(resolve => mainWindow.requestAnimationFrame(() => mainWindow.requestAnimationFrame(() => resolve())));
|
||||
|
||||
const items = [...widget.domNode.querySelectorAll('.chat-question-list-item')] as HTMLElement[];
|
||||
const active = mainWindow.document.activeElement as HTMLElement | null;
|
||||
|
||||
assert.deepStrictEqual({
|
||||
focusedElementOwnsActiveDescendant: !!active?.hasAttribute('aria-activedescendant'),
|
||||
focusIsOnList: active === list,
|
||||
focusIsOnAnOption: items.includes(active as HTMLElement),
|
||||
optionsAreNotTabStops: items.every(i => i.tabIndex === -1),
|
||||
}, {
|
||||
focusedElementOwnsActiveDescendant: true,
|
||||
focusIsOnList: true,
|
||||
focusIsOnAnOption: false,
|
||||
optionsAreNotTabStops: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('hasSameContent', () => {
|
||||
test('returns true for same carousel instance', () => {
|
||||
const carousel = createMockCarousel([
|
||||
|
||||
+15
-7
@@ -16,10 +16,11 @@ import { DocumentPasteTriggerKind, ICustomEdit } from '../../../../../../../../e
|
||||
import { ITextModel } from '../../../../../../../../editor/common/model.js';
|
||||
import { IModelService } from '../../../../../../../../editor/common/services/model.js';
|
||||
import { TestInstantiationService } from '../../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
|
||||
import { IConfigurationService } from '../../../../../../../../platform/configuration/common/configuration.js';
|
||||
import { ILogService } from '../../../../../../../../platform/log/common/log.js';
|
||||
import { IChatPasteTarget, IChatPasteTargetService } from '../../../../../browser/chat.js';
|
||||
import { IChatSessionsService } from '../../../../../common/chatSessionsService.js';
|
||||
import { CHAT_ATTACHMENT_MIME_TYPE, createPastedTextArtifact, PasteTextProvider } from '../../../../../browser/widget/input/editor/chatPasteProviders.js';
|
||||
import { CHAT_ATTACHMENT_MIME_TYPE, createPastedTextArtifact, pastedTextArtifactDefaultMinLength, PasteTextProvider } from '../../../../../browser/widget/input/editor/chatPasteProviders.js';
|
||||
import { ChatPasteAttachmentMetadata, IChatRequestVariableEntry } from '../../../../../common/attachments/chatVariableEntries.js';
|
||||
import { isSupportedChatFileScheme } from '../../../../../common/constants.js';
|
||||
import { ChatResponseResource } from '../../../../../common/model/chatModel.js';
|
||||
@@ -41,14 +42,16 @@ suite('Chat Paste Providers', () => {
|
||||
});
|
||||
|
||||
test('creates sequential artifacts only for long pasted text', () => {
|
||||
const longText = 'x'.repeat(1000);
|
||||
const longText = `${'x'.repeat(10000)}\n`.repeat(10);
|
||||
const first = createPastedTextArtifact(longText, []);
|
||||
assert.ok(first);
|
||||
const second = createPastedTextArtifact(`${longText}\nsecond line`, [first.attachment]);
|
||||
assert.ok(second);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
belowThreshold: createPastedTextArtifact('x'.repeat(999), []),
|
||||
belowLengthThreshold: createPastedTextArtifact(`${'x'.repeat(100)}\n`.repeat(10), []),
|
||||
belowLineThreshold: createPastedTextArtifact('x'.repeat(20000), []),
|
||||
respectsConfiguredThreshold: !!createPastedTextArtifact(`${'x'.repeat(10)}\n`.repeat(10), [], { minLength: 100 }),
|
||||
first: {
|
||||
name: first.attachment.name,
|
||||
referenceText: first.referenceText,
|
||||
@@ -65,21 +68,23 @@ suite('Chat Paste Providers', () => {
|
||||
pastedLines: second.attachment.pastedLines,
|
||||
},
|
||||
}, {
|
||||
belowThreshold: undefined,
|
||||
belowLengthThreshold: undefined,
|
||||
belowLineThreshold: undefined,
|
||||
respectsConfiguredThreshold: true,
|
||||
first: {
|
||||
name: 'Pasted text #1',
|
||||
referenceText: '#attachment:Pasted text #1',
|
||||
codeIsPreserved: true,
|
||||
language: 'plaintext',
|
||||
fileName: 'Pasted text #1',
|
||||
pastedLines: '1 line',
|
||||
pastedLines: '11 lines',
|
||||
metadataKind: 'paste',
|
||||
isTextArtifact: true,
|
||||
},
|
||||
second: {
|
||||
name: 'Pasted text #2',
|
||||
referenceText: '#attachment:Pasted text #2',
|
||||
pastedLines: '2 lines',
|
||||
pastedLines: '12 lines',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -118,12 +123,15 @@ suite('Chat Paste Providers', () => {
|
||||
pasteTargetService,
|
||||
new class extends mock<IModelService>() { },
|
||||
new class extends mock<ILogService>() { },
|
||||
new class extends mock<IConfigurationService>() {
|
||||
override getValue<T>(): T { return pastedTextArtifactDefaultMinLength as T; }
|
||||
},
|
||||
);
|
||||
const model = upcastPartial<ITextModel>({
|
||||
uri: modelUri,
|
||||
getOffsetAt: position => position.column - 1,
|
||||
});
|
||||
const longText = 'x'.repeat(1000);
|
||||
const longText = `${'x'.repeat(10000)}\n`.repeat(10);
|
||||
const transferOf = (entries: Record<string, string>) => {
|
||||
const transfer = new VSDataTransfer();
|
||||
for (const [mime, value] of Object.entries(entries)) {
|
||||
|
||||
@@ -106,6 +106,8 @@ import { ISessionsManagementService } from '../../../../sessions/services/sessio
|
||||
// eslint-disable-next-line local/code-import-patterns
|
||||
import { ISessionsService } from '../../../../sessions/services/sessions/browser/sessionsService.js';
|
||||
// eslint-disable-next-line local/code-import-patterns
|
||||
import { ISessionChangesStatsCache, SessionChangesStatsCache } from '../../../../sessions/services/sessions/common/sessionChangesStatsCache.js';
|
||||
// eslint-disable-next-line local/code-import-patterns
|
||||
import { ICodeReviewService, PRReviewStateKind } from '../../../../sessions/contrib/codeReview/browser/codeReviewService.js';
|
||||
import { constObservable } from '../../../../base/common/observable.js';
|
||||
|
||||
@@ -685,6 +687,10 @@ export function createEditorServices(disposables: DisposableStore, options?: Cre
|
||||
activeSession: constObservable(undefined),
|
||||
});
|
||||
|
||||
// The real cache: it only reads and writes the (null) storage service, and
|
||||
// the changes pill it feeds reads it directly.
|
||||
define(ISessionChangesStatsCache, SessionChangesStatsCache);
|
||||
|
||||
definePartialInstance(ICodeReviewService, {
|
||||
_serviceBrand: undefined,
|
||||
getPRReviewState: () => constObservable({ kind: PRReviewStateKind.None }),
|
||||
|
||||
Reference in New Issue
Block a user